Skip to main content

azul_css/props/style/
scrollbar.rs

1//! CSS properties for styling scrollbars.
2
3use alloc::string::{String, ToString};
4use crate::corety::AzString;
5
6use crate::props::{
7    basic::{
8        color::{parse_css_color, ColorU, CssColorParseError, CssColorParseErrorOwned},
9    },
10    formatter::PrintAsCssValue,
11    layout::{
12        dimensions::LayoutWidth,
13        spacing::{LayoutPaddingLeft, LayoutPaddingRight},
14    },
15    style::background::StyleBackgroundContent,
16};
17
18// ============================================================================
19// CSS Standard Scroll Behavior Properties
20// ============================================================================
21
22/// CSS `scroll-behavior` property - controls smooth scrolling
23/// <https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-behavior>
24#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
25#[repr(C)]
26pub enum ScrollBehavior {
27    /// Scrolling jumps instantly to the final position
28    #[default]
29    Auto,
30    /// Scrolling animates smoothly to the final position
31    Smooth,
32}
33
34impl PrintAsCssValue for ScrollBehavior {
35    fn print_as_css_value(&self) -> String {
36        match self {
37            Self::Auto => "auto".to_string(),
38            Self::Smooth => "smooth".to_string(),
39        }
40    }
41}
42
43/// CSS `overscroll-behavior` property - controls overscroll effects
44/// <https://developer.mozilla.org/en-US/docs/Web/CSS/overscroll-behavior>
45#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
46#[repr(C)]
47pub enum OverscrollBehavior {
48    /// Default scroll overflow behavior (bounce/glow effects, scroll chaining)
49    #[default]
50    Auto,
51    /// Prevents scroll chaining to parent elements, but allows local overscroll effects
52    Contain,
53    /// No scroll chaining and no overscroll effects (hard stop at boundaries)
54    None,
55}
56
57impl PrintAsCssValue for OverscrollBehavior {
58    fn print_as_css_value(&self) -> String {
59        match self {
60            Self::Auto => "auto".to_string(),
61            Self::Contain => "contain".to_string(),
62            Self::None => "none".to_string(),
63        }
64    }
65}
66
67// ============================================================================
68// Extended Scroll Configuration (Azul-specific)
69// ============================================================================
70
71/// Scroll physics configuration for momentum scrolling
72///
73/// This controls how scrolling feels - the "weight" and "friction" of the scroll.
74/// Different platforms have different scroll physics (iOS vs Android vs Windows).
75#[derive(Debug, Copy, Clone, PartialEq, PartialOrd)]
76#[repr(C)]
77pub struct ScrollPhysics {
78    /// Smooth scroll animation duration in milliseconds (default: 300ms)
79    /// Only used when scroll-behavior: smooth
80    pub smooth_scroll_duration_ms: u32,
81
82    /// Deceleration rate for momentum scrolling (0.0 = instant stop, 1.0 = never stops)
83    /// Typical values: 0.95 (fast deceleration) to 0.998 (slow, iOS-like)
84    /// Default: 0.95
85    pub deceleration_rate: f32,
86
87    /// Minimum velocity threshold to start momentum scrolling (pixels/second)
88    /// Below this, scrolling stops immediately. Default: 50.0
89    pub min_velocity_threshold: f32,
90
91    /// Maximum scroll velocity (pixels/second). Default: 8000.0
92    pub max_velocity: f32,
93
94    /// Scroll wheel multiplier. Default: 1.0
95    /// Values > 1.0 make scrolling faster, < 1.0 slower
96    pub wheel_multiplier: f32,
97
98    /// Whether to invert scroll direction (natural scrolling). Default: false
99    pub invert_direction: bool,
100
101    /// Overscroll elasticity (0.0 = no bounce, 1.0 = full bounce like iOS)
102    /// Only applies when overscroll-behavior: auto. Default: 0.0 (no bounce)
103    pub overscroll_elasticity: f32,
104
105    /// Maximum overscroll distance in pixels before rubber-banding stops
106    /// Default: 100.0
107    pub max_overscroll_distance: f32,
108
109    /// Bounce-back duration when releasing overscroll (milliseconds)
110    /// Default: 400
111    pub bounce_back_duration_ms: u32,
112
113    /// Timer tick interval in milliseconds for the scroll physics timer.
114    /// Should match the monitor refresh rate (e.g. 16ms for 60Hz, 8ms for 120Hz).
115    /// Default: 16 (60 Hz)
116    pub timer_interval_ms: u32,
117
118    /// Spring duration for target-seeking animated scrolls
119    /// (`scroll_to_animated`) that originate from a MOUSE WHEEL. Wheel
120    /// steps animated with the trackpad's long bounce constant feel
121    /// jarring - a discrete click wants a short, snappy glide. Other
122    /// devices use `bounce_back_duration_ms`. Default: 120.
123    pub wheel_animate_bounce_ms: u32,
124}
125
126impl Default for ScrollPhysics {
127    fn default() -> Self {
128        Self {
129            smooth_scroll_duration_ms: 300,
130            deceleration_rate: 0.95,
131            min_velocity_threshold: 50.0,
132            max_velocity: 8000.0,
133            wheel_multiplier: 1.0,
134            invert_direction: false,
135            overscroll_elasticity: 0.0, // No bounce by default (Windows-like)
136            max_overscroll_distance: 100.0,
137            bounce_back_duration_ms: 400,
138            timer_interval_ms: 16,
139            wheel_animate_bounce_ms: 120,
140        }
141    }
142}
143
144impl ScrollPhysics {
145    /// iOS-like scroll physics with momentum and bounce
146    #[must_use] pub const fn ios() -> Self {
147        Self {
148            smooth_scroll_duration_ms: 300,
149            deceleration_rate: 0.998,
150            min_velocity_threshold: 20.0,
151            max_velocity: 8000.0,
152            wheel_multiplier: 1.0,
153            invert_direction: true, // Natural scrolling
154            overscroll_elasticity: 0.5,
155            max_overscroll_distance: 120.0,
156            bounce_back_duration_ms: 500,
157            timer_interval_ms: 16,
158            wheel_animate_bounce_ms: 120,
159        }
160    }
161
162    /// macOS-like scroll physics
163    #[must_use] pub const fn macos() -> Self {
164        Self {
165            smooth_scroll_duration_ms: 250,
166            deceleration_rate: 0.997,
167            min_velocity_threshold: 30.0,
168            max_velocity: 6000.0,
169            wheel_multiplier: 1.0,
170            invert_direction: true, // Natural scrolling by default
171            overscroll_elasticity: 0.3,
172            max_overscroll_distance: 80.0,
173            bounce_back_duration_ms: 400,
174            timer_interval_ms: 16,
175            wheel_animate_bounce_ms: 120,
176        }
177    }
178
179    /// Windows-like scroll physics (no momentum, no bounce)
180    #[must_use] pub const fn windows() -> Self {
181        Self {
182            smooth_scroll_duration_ms: 200,
183            deceleration_rate: 0.9,
184            min_velocity_threshold: 100.0,
185            max_velocity: 4000.0,
186            wheel_multiplier: 1.0,
187            invert_direction: false,
188            overscroll_elasticity: 0.0,
189            max_overscroll_distance: 0.0,
190            bounce_back_duration_ms: 200,
191            timer_interval_ms: 16,
192            wheel_animate_bounce_ms: 120,
193        }
194    }
195
196    /// Android-like scroll physics
197    #[must_use] pub const fn android() -> Self {
198        Self {
199            smooth_scroll_duration_ms: 250,
200            deceleration_rate: 0.996,
201            min_velocity_threshold: 40.0,
202            max_velocity: 8000.0,
203            wheel_multiplier: 1.0,
204            invert_direction: false,
205            overscroll_elasticity: 0.2, // Subtle glow effect
206            max_overscroll_distance: 60.0,
207            bounce_back_duration_ms: 300,
208            timer_interval_ms: 16,
209            wheel_animate_bounce_ms: 120,
210        }
211    }
212}
213
214impl_option!(
215    ScrollPhysics,
216    OptionScrollPhysics,
217    [Debug, Copy, Clone, PartialEq, PartialOrd]
218);
219
220// ============================================================================
221// Scrollbar Visibility Mode (CSS: -azul-scrollbar-visibility)
222// ============================================================================
223
224/// Controls when the scrollbar is displayed.
225///
226/// This is a per-element CSS property (`-azul-scrollbar-visibility`) that
227/// determines the scrollbar presentation style. It interacts with the
228/// OS-level `ScrollbarPreferences.visibility` (from System Preferences)
229/// when set to `Auto`.
230///
231/// - `Always`: Classic, always-visible scrollbar (Chrome/Windows/Linux default).
232///   Scrollbar reserves layout space.
233/// - `WhenScrolling`: Overlay scrollbar that fades in on scroll activity
234///   and fades out after a delay. Does not reserve layout space.
235/// - `Auto`: Use the OS preference. On macOS this typically means `WhenScrolling`,
236///   on Windows/Linux this typically means `Always`.
237#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
238#[repr(C)]
239pub enum ScrollbarVisibilityMode {
240    /// Scrollbar is always visible (Chrome/Windows/Linux default).
241    /// Reserves layout space.
242    #[default]
243    Always,
244    /// Scrollbar appears on scroll and fades out after inactivity.
245    /// Does not reserve layout space (overlay).
246    WhenScrolling,
247    /// Use the OS-level scrollbar preference.
248    Auto,
249}
250
251impl PrintAsCssValue for ScrollbarVisibilityMode {
252    fn print_as_css_value(&self) -> String {
253        match self {
254            Self::Always => "always".to_string(),
255            Self::WhenScrolling => "when-scrolling".to_string(),
256            Self::Auto => "auto".to_string(),
257        }
258    }
259}
260
261// ============================================================================
262// Scrollbar Fade Delay (CSS: -azul-scrollbar-fade-delay)
263// ============================================================================
264
265/// Time in milliseconds before the overlay scrollbar starts fading out.
266///
267/// A value of 0 means the scrollbar never fades (always visible).
268/// Typical values: 500ms (macOS), 0ms (Windows).
269///
270/// CSS syntax: `-azul-scrollbar-fade-delay: 500ms;` or `-azul-scrollbar-fade-delay: 0;`
271#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
272#[repr(C)]
273pub struct ScrollbarFadeDelay {
274    /// Delay in milliseconds
275    pub ms: u32,
276}
277
278impl ScrollbarFadeDelay {
279    #[must_use] pub const fn new(ms: u32) -> Self { Self { ms } }
280    pub const ZERO: Self = Self { ms: 0 };
281}
282
283impl PrintAsCssValue for ScrollbarFadeDelay {
284    fn print_as_css_value(&self) -> String {
285        if self.ms == 0 { "0".to_string() } else { format!("{}ms", self.ms) }
286    }
287}
288
289// ============================================================================
290// Scrollbar Fade Duration (CSS: -azul-scrollbar-fade-duration)
291// ============================================================================
292
293/// Duration in milliseconds of the scrollbar fade-out animation.
294///
295/// A value of 0 means instant disappearance (no animation).
296/// Typical values: 200ms (macOS), 0ms (Windows).
297///
298/// CSS syntax: `-azul-scrollbar-fade-duration: 200ms;` or `-azul-scrollbar-fade-duration: 0;`
299#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
300#[repr(C)]
301pub struct ScrollbarFadeDuration {
302    /// Duration in milliseconds
303    pub ms: u32,
304}
305
306impl ScrollbarFadeDuration {
307    #[must_use] pub const fn new(ms: u32) -> Self { Self { ms } }
308    pub const ZERO: Self = Self { ms: 0 };
309}
310
311impl PrintAsCssValue for ScrollbarFadeDuration {
312    fn print_as_css_value(&self) -> String {
313        if self.ms == 0 { "0".to_string() } else { format!("{}ms", self.ms) }
314    }
315}
316
317// ============================================================================
318// Per-node Overflow Scrolling Mode (CSS: -azul-overflow-scrolling)
319// ============================================================================
320
321/// Controls per-node rubber-banding / momentum scrolling behavior.
322///
323/// Analogous to `-webkit-overflow-scrolling` on iOS Safari.
324///
325/// - `Auto`: Use the global `ScrollPhysics` from `SystemStyle`. On platforms
326///   with `overscroll_elasticity == 0.0` (e.g. Windows), this means no rubber-banding.
327/// - `Touch`: Force momentum scrolling with rubber-banding on this node,
328///   regardless of the global `ScrollPhysics` setting. Uses iOS-like elasticity
329///   if the global elasticity is zero.
330#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
331#[repr(C)]
332pub enum OverflowScrolling {
333    /// Use the global scroll physics (platform default). No rubber-banding on Windows.
334    #[default]
335    Auto,
336    /// Force rubber-banding / momentum scrolling on this node (like iOS/macOS).
337    Touch,
338}
339
340impl PrintAsCssValue for OverflowScrolling {
341    fn print_as_css_value(&self) -> String {
342        match self {
343            Self::Auto => "auto".to_string(),
344            Self::Touch => "touch".to_string(),
345        }
346    }
347}
348
349// ============================================================================
350// Standard Properties
351// ============================================================================
352
353/// Represents the standard `scrollbar-width` property.
354#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
355#[repr(C)]
356#[derive(Default)]
357pub enum LayoutScrollbarWidth {
358    #[default]
359    Auto,
360    Thin,
361    None,
362}
363
364
365impl PrintAsCssValue for LayoutScrollbarWidth {
366    fn print_as_css_value(&self) -> String {
367        match self {
368            Self::Auto => "auto".to_string(),
369            Self::Thin => "thin".to_string(),
370            Self::None => "none".to_string(),
371        }
372    }
373}
374
375/// Wrapper struct for custom scrollbar colors (thumb and track)
376#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
377#[repr(C)]
378pub struct ScrollbarColorCustom {
379    pub thumb: ColorU,
380    pub track: ColorU,
381}
382
383/// Represents the standard `scrollbar-color` property.
384#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
385#[repr(C, u8)]
386#[derive(Default)]
387pub enum StyleScrollbarColor {
388    #[default]
389    Auto,
390    Custom(ScrollbarColorCustom),
391}
392
393
394impl PrintAsCssValue for StyleScrollbarColor {
395    fn print_as_css_value(&self) -> String {
396        match self {
397            Self::Auto => "auto".to_string(),
398            Self::Custom(c) => format!("{} {}", c.thumb.to_hash(), c.track.to_hash()),
399        }
400    }
401}
402
403// -- -webkit-prefixed Properties --
404
405/// Holds info necessary for layouting / styling -webkit-scrollbar properties.
406#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
407#[repr(C)]
408pub struct ScrollbarInfo {
409    /// Total width (or height for vertical scrollbars) of the scrollbar in pixels
410    pub width: LayoutWidth,
411    /// Padding of the scrollbar tracker, in pixels. The inner bar is `width - padding` pixels
412    /// wide.
413    pub padding_left: LayoutPaddingLeft,
414    /// Padding of the scrollbar (right)
415    pub padding_right: LayoutPaddingRight,
416    /// Style of the scrollbar background
417    /// (`-webkit-scrollbar` / `-webkit-scrollbar-track` / `-webkit-scrollbar-track-piece`
418    /// combined)
419    pub track: StyleBackgroundContent,
420    /// Style of the scrollbar thumbs (the "up" / "down" arrows), (`-webkit-scrollbar-thumb`)
421    pub thumb: StyleBackgroundContent,
422    /// Styles the directional buttons on the scrollbar (`-webkit-scrollbar-button`)
423    pub button: StyleBackgroundContent,
424    /// If two scrollbars are present, addresses the (usually) bottom corner
425    /// of the scrollable element, where two scrollbars might meet (`-webkit-scrollbar-corner`)
426    pub corner: StyleBackgroundContent,
427    /// Addresses the draggable resizing handle that appears above the
428    /// `corner` at the bottom corner of some elements (`-webkit-resizer`)
429    pub resizer: StyleBackgroundContent,
430    /// Whether to clip the scrollbar to the container's border-radius.
431    /// When true, if the container has rounded corners, the scrollbar will be
432    /// clipped to those rounded corners instead of having rectangular edges.
433    /// Default is false for classic scrollbars, true for overlay scrollbars.
434    pub clip_to_container_border: bool,
435    /// Scroll behavior for this scrollbar's container (auto or smooth)
436    pub scroll_behavior: ScrollBehavior,
437    /// Overscroll behavior for the X axis
438    pub overscroll_behavior_x: OverscrollBehavior,
439    /// Overscroll behavior for the Y axis  
440    pub overscroll_behavior_y: OverscrollBehavior,
441    /// Per-node overflow scrolling mode (`-azul-overflow-scrolling: auto | touch`)
442    /// `Touch` forces rubber-banding on this node even when the global physics has no bounce.
443    pub overflow_scrolling: OverflowScrolling,
444}
445
446impl Default for ScrollbarInfo {
447    fn default() -> Self {
448        SCROLLBAR_CLASSIC_LIGHT
449    }
450}
451
452impl PrintAsCssValue for ScrollbarInfo {
453    fn print_as_css_value(&self) -> String {
454        // This is a custom format, not standard CSS
455        format!(
456            "width: {}; padding-left: {}; padding-right: {}; track: {}; thumb: {}; button: {}; \
457             corner: {}; resizer: {}",
458            self.width.print_as_css_value(),
459            self.padding_left.print_as_css_value(),
460            self.padding_right.print_as_css_value(),
461            self.track.print_as_css_value(),
462            self.thumb.print_as_css_value(),
463            self.button.print_as_css_value(),
464            self.corner.print_as_css_value(),
465            self.resizer.print_as_css_value(),
466        )
467    }
468}
469
470/// Scrollbar style for both horizontal and vertical scrollbars.
471#[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
472#[repr(C)]
473pub struct ScrollbarStyle {
474    /// Horizontal scrollbar style, if any
475    pub horizontal: ScrollbarInfo,
476    /// Vertical scrollbar style, if any
477    pub vertical: ScrollbarInfo,
478}
479
480impl PrintAsCssValue for ScrollbarStyle {
481    fn print_as_css_value(&self) -> String {
482        // This is a custom format, not standard CSS
483        format!(
484            "horz({}), vert({})",
485            self.horizontal.print_as_css_value(),
486            self.vertical.print_as_css_value()
487        )
488    }
489}
490
491// Formatting to Rust code
492impl crate::codegen::format::FormatAsRustCode for ScrollbarStyle {
493    fn format_as_rust_code(&self, tabs: usize) -> String {
494        let t = String::from("    ").repeat(tabs);
495        let t1 = String::from("    ").repeat(tabs + 1);
496        format!(
497            "ScrollbarStyle {{\r\n{}horizontal: {},\r\n{}vertical: {},\r\n{}}}",
498            t1,
499            crate::codegen::format::format_scrollbar_info(&self.horizontal, tabs + 1),
500            t1,
501            crate::codegen::format::format_scrollbar_info(&self.vertical, tabs + 1),
502            t,
503        )
504    }
505}
506
507impl crate::codegen::format::FormatAsRustCode for LayoutScrollbarWidth {
508    fn format_as_rust_code(&self, _tabs: usize) -> String {
509        match self {
510            Self::Auto => String::from("LayoutScrollbarWidth::Auto"),
511            Self::Thin => String::from("LayoutScrollbarWidth::Thin"),
512            Self::None => String::from("LayoutScrollbarWidth::None"),
513        }
514    }
515}
516
517impl crate::codegen::format::FormatAsRustCode for StyleScrollbarColor {
518    fn format_as_rust_code(&self, _tabs: usize) -> String {
519        match self {
520            Self::Auto => String::from("StyleScrollbarColor::Auto"),
521            Self::Custom(c) => format!(
522                "StyleScrollbarColor::Custom(ScrollbarColorCustom {{ thumb: {}, track: {} }})",
523                crate::codegen::format::format_color_value(&c.thumb),
524                crate::codegen::format::format_color_value(&c.track)
525            ),
526        }
527    }
528}
529
530impl crate::codegen::format::FormatAsRustCode for ScrollbarVisibilityMode {
531    fn format_as_rust_code(&self, _tabs: usize) -> String {
532        match self {
533            Self::Always => String::from("ScrollbarVisibilityMode::Always"),
534            Self::WhenScrolling => String::from("ScrollbarVisibilityMode::WhenScrolling"),
535            Self::Auto => String::from("ScrollbarVisibilityMode::Auto"),
536        }
537    }
538}
539
540impl crate::codegen::format::FormatAsRustCode for ScrollbarFadeDelay {
541    fn format_as_rust_code(&self, _tabs: usize) -> String {
542        format!("ScrollbarFadeDelay::new({})", self.ms)
543    }
544}
545
546impl crate::codegen::format::FormatAsRustCode for ScrollbarFadeDuration {
547    fn format_as_rust_code(&self, _tabs: usize) -> String {
548        format!("ScrollbarFadeDuration::new({})", self.ms)
549    }
550}
551
552// --- Final Computed Style ---
553
554/// The final, resolved style for a scrollbar, after considering both
555/// standard and -webkit- properties. This struct is intended for use by the layout engine.
556#[derive(Debug, Clone, PartialEq, Eq)]
557pub struct ComputedScrollbarStyle {
558    /// The width of the scrollbar. `None` signifies `scrollbar-width: none`.
559    pub width: Option<LayoutWidth>,
560    /// The color of the scrollbar thumb. `None` means use UA default.
561    pub thumb_color: Option<ColorU>,
562    /// The color of the scrollbar track. `None` means use UA default.
563    pub track_color: Option<ColorU>,
564}
565
566impl Default for ComputedScrollbarStyle {
567    fn default() -> Self {
568        let default_info = ScrollbarInfo::default();
569        Self {
570            width: Some(default_info.width), // Default width from UA/platform
571            thumb_color: match default_info.thumb {
572                StyleBackgroundContent::Color(c) => Some(c),
573                _ => None,
574            },
575            track_color: match default_info.track {
576                StyleBackgroundContent::Color(c) => Some(c),
577                _ => None,
578            },
579        }
580    }
581}
582
583// --- Default Style Constants ---
584
585/// A classic light-themed scrollbar, similar to older Windows versions.
586pub const SCROLLBAR_CLASSIC_LIGHT: ScrollbarInfo = ScrollbarInfo {
587    width: LayoutWidth::Px(crate::props::basic::pixel::PixelValue::const_px(17)),
588    padding_left: LayoutPaddingLeft {
589        inner: crate::props::basic::pixel::PixelValue::const_px(2),
590    },
591    padding_right: LayoutPaddingRight {
592        inner: crate::props::basic::pixel::PixelValue::const_px(2),
593    },
594    track: StyleBackgroundContent::Color(ColorU {
595        r: 241,
596        g: 241,
597        b: 241,
598        a: 255,
599    }),
600    thumb: StyleBackgroundContent::Color(ColorU {
601        r: 193,
602        g: 193,
603        b: 193,
604        a: 255,
605    }),
606    button: StyleBackgroundContent::Color(ColorU {
607        r: 163,
608        g: 163,
609        b: 163,
610        a: 255,
611    }),
612    corner: StyleBackgroundContent::Color(ColorU {
613        r: 241,
614        g: 241,
615        b: 241,
616        a: 255,
617    }),
618    resizer: StyleBackgroundContent::Color(ColorU {
619        r: 241,
620        g: 241,
621        b: 241,
622        a: 255,
623    }),
624    clip_to_container_border: false,
625    scroll_behavior: ScrollBehavior::Auto,
626    overscroll_behavior_x: OverscrollBehavior::Auto,
627    overscroll_behavior_y: OverscrollBehavior::Auto,
628    overflow_scrolling: OverflowScrolling::Auto,
629};
630
631/// A classic dark-themed scrollbar.
632pub const SCROLLBAR_CLASSIC_DARK: ScrollbarInfo = ScrollbarInfo {
633    width: LayoutWidth::Px(crate::props::basic::pixel::PixelValue::const_px(17)),
634    padding_left: LayoutPaddingLeft {
635        inner: crate::props::basic::pixel::PixelValue::const_px(2),
636    },
637    padding_right: LayoutPaddingRight {
638        inner: crate::props::basic::pixel::PixelValue::const_px(2),
639    },
640    track: StyleBackgroundContent::Color(ColorU {
641        r: 45,
642        g: 45,
643        b: 45,
644        a: 255,
645    }),
646    thumb: StyleBackgroundContent::Color(ColorU {
647        r: 100,
648        g: 100,
649        b: 100,
650        a: 255,
651    }),
652    button: StyleBackgroundContent::Color(ColorU {
653        r: 120,
654        g: 120,
655        b: 120,
656        a: 255,
657    }),
658    corner: StyleBackgroundContent::Color(ColorU {
659        r: 45,
660        g: 45,
661        b: 45,
662        a: 255,
663    }),
664    resizer: StyleBackgroundContent::Color(ColorU {
665        r: 45,
666        g: 45,
667        b: 45,
668        a: 255,
669    }),
670    clip_to_container_border: false,
671    scroll_behavior: ScrollBehavior::Auto,
672    overscroll_behavior_x: OverscrollBehavior::Auto,
673    overscroll_behavior_y: OverscrollBehavior::Auto,
674    overflow_scrolling: OverflowScrolling::Auto,
675};
676
677/// A modern, thin, overlay scrollbar inspired by macOS (Light Theme).
678pub const SCROLLBAR_MACOS_LIGHT: ScrollbarInfo = ScrollbarInfo {
679    width: LayoutWidth::Px(crate::props::basic::pixel::PixelValue::const_px(8)),
680    padding_left: LayoutPaddingLeft {
681        inner: crate::props::basic::pixel::PixelValue::const_px(0),
682    },
683    padding_right: LayoutPaddingRight {
684        inner: crate::props::basic::pixel::PixelValue::const_px(0),
685    },
686    track: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
687    thumb: StyleBackgroundContent::Color(ColorU {
688        r: 0,
689        g: 0,
690        b: 0,
691        a: 100,
692    }), // semi-transparent black
693    button: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
694    corner: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
695    resizer: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
696    clip_to_container_border: true, // Overlay scrollbars should clip to rounded borders
697    scroll_behavior: ScrollBehavior::Smooth,
698    overscroll_behavior_x: OverscrollBehavior::Auto,
699    overscroll_behavior_y: OverscrollBehavior::Auto,
700    overflow_scrolling: OverflowScrolling::Auto,
701};
702
703/// A modern, thin, overlay scrollbar inspired by macOS (Dark Theme).
704pub const SCROLLBAR_MACOS_DARK: ScrollbarInfo = ScrollbarInfo {
705    width: LayoutWidth::Px(crate::props::basic::pixel::PixelValue::const_px(8)),
706    padding_left: LayoutPaddingLeft {
707        inner: crate::props::basic::pixel::PixelValue::const_px(0),
708    },
709    padding_right: LayoutPaddingRight {
710        inner: crate::props::basic::pixel::PixelValue::const_px(0),
711    },
712    track: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
713    thumb: StyleBackgroundContent::Color(ColorU {
714        r: 255,
715        g: 255,
716        b: 255,
717        a: 100,
718    }), // semi-transparent white
719    button: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
720    corner: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
721    resizer: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
722    clip_to_container_border: true, // Overlay scrollbars should clip to rounded borders
723    scroll_behavior: ScrollBehavior::Smooth,
724    overscroll_behavior_x: OverscrollBehavior::Auto,
725    overscroll_behavior_y: OverscrollBehavior::Auto,
726    overflow_scrolling: OverflowScrolling::Auto,
727};
728
729/// A modern scrollbar inspired by Windows 11 (Light Theme).
730pub const SCROLLBAR_WINDOWS_LIGHT: ScrollbarInfo = ScrollbarInfo {
731    width: LayoutWidth::Px(crate::props::basic::pixel::PixelValue::const_px(12)),
732    padding_left: LayoutPaddingLeft {
733        inner: crate::props::basic::pixel::PixelValue::const_px(0),
734    },
735    padding_right: LayoutPaddingRight {
736        inner: crate::props::basic::pixel::PixelValue::const_px(0),
737    },
738    track: StyleBackgroundContent::Color(ColorU {
739        r: 241,
740        g: 241,
741        b: 241,
742        a: 255,
743    }),
744    thumb: StyleBackgroundContent::Color(ColorU {
745        r: 130,
746        g: 130,
747        b: 130,
748        a: 255,
749    }),
750    button: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
751    corner: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
752    resizer: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
753    clip_to_container_border: false,
754    scroll_behavior: ScrollBehavior::Auto,
755    overscroll_behavior_x: OverscrollBehavior::None,
756    overscroll_behavior_y: OverscrollBehavior::None,
757    overflow_scrolling: OverflowScrolling::Auto,
758};
759
760/// A modern scrollbar inspired by Windows 11 (Dark Theme).
761pub const SCROLLBAR_WINDOWS_DARK: ScrollbarInfo = ScrollbarInfo {
762    width: LayoutWidth::Px(crate::props::basic::pixel::PixelValue::const_px(12)),
763    padding_left: LayoutPaddingLeft {
764        inner: crate::props::basic::pixel::PixelValue::const_px(0),
765    },
766    padding_right: LayoutPaddingRight {
767        inner: crate::props::basic::pixel::PixelValue::const_px(0),
768    },
769    track: StyleBackgroundContent::Color(ColorU {
770        r: 32,
771        g: 32,
772        b: 32,
773        a: 255,
774    }),
775    thumb: StyleBackgroundContent::Color(ColorU {
776        r: 110,
777        g: 110,
778        b: 110,
779        a: 255,
780    }),
781    button: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
782    corner: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
783    resizer: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
784    clip_to_container_border: false,
785    scroll_behavior: ScrollBehavior::Auto,
786    overscroll_behavior_x: OverscrollBehavior::None,
787    overscroll_behavior_y: OverscrollBehavior::None,
788    overflow_scrolling: OverflowScrolling::Auto,
789};
790
791/// A modern, thin, overlay scrollbar inspired by iOS (Light Theme).
792pub const SCROLLBAR_IOS_LIGHT: ScrollbarInfo = ScrollbarInfo {
793    width: LayoutWidth::Px(crate::props::basic::pixel::PixelValue::const_px(7)),
794    padding_left: LayoutPaddingLeft {
795        inner: crate::props::basic::pixel::PixelValue::const_px(0),
796    },
797    padding_right: LayoutPaddingRight {
798        inner: crate::props::basic::pixel::PixelValue::const_px(0),
799    },
800    track: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
801    thumb: StyleBackgroundContent::Color(ColorU {
802        r: 0,
803        g: 0,
804        b: 0,
805        a: 102,
806    }), // rgba(0,0,0,0.4)
807    button: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
808    corner: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
809    resizer: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
810    clip_to_container_border: true, // Overlay scrollbars should clip to rounded borders
811    scroll_behavior: ScrollBehavior::Smooth,
812    overscroll_behavior_x: OverscrollBehavior::Auto,
813    overscroll_behavior_y: OverscrollBehavior::Auto,
814    overflow_scrolling: OverflowScrolling::Auto,
815};
816
817/// A modern, thin, overlay scrollbar inspired by iOS (Dark Theme).
818pub const SCROLLBAR_IOS_DARK: ScrollbarInfo = ScrollbarInfo {
819    width: LayoutWidth::Px(crate::props::basic::pixel::PixelValue::const_px(7)),
820    padding_left: LayoutPaddingLeft {
821        inner: crate::props::basic::pixel::PixelValue::const_px(0),
822    },
823    padding_right: LayoutPaddingRight {
824        inner: crate::props::basic::pixel::PixelValue::const_px(0),
825    },
826    track: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
827    thumb: StyleBackgroundContent::Color(ColorU {
828        r: 255,
829        g: 255,
830        b: 255,
831        a: 102,
832    }), // rgba(255,255,255,0.4)
833    button: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
834    corner: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
835    resizer: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
836    clip_to_container_border: true, // Overlay scrollbars should clip to rounded borders
837    scroll_behavior: ScrollBehavior::Smooth,
838    overscroll_behavior_x: OverscrollBehavior::Auto,
839    overscroll_behavior_y: OverscrollBehavior::Auto,
840    overflow_scrolling: OverflowScrolling::Auto,
841};
842
843/// A modern, thin, overlay scrollbar inspired by Android (Light Theme).
844pub const SCROLLBAR_ANDROID_LIGHT: ScrollbarInfo = ScrollbarInfo {
845    width: LayoutWidth::Px(crate::props::basic::pixel::PixelValue::const_px(6)),
846    padding_left: LayoutPaddingLeft {
847        inner: crate::props::basic::pixel::PixelValue::const_px(0),
848    },
849    padding_right: LayoutPaddingRight {
850        inner: crate::props::basic::pixel::PixelValue::const_px(0),
851    },
852    track: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
853    thumb: StyleBackgroundContent::Color(ColorU {
854        r: 0,
855        g: 0,
856        b: 0,
857        a: 102,
858    }), // rgba(0,0,0,0.4)
859    button: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
860    corner: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
861    resizer: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
862    clip_to_container_border: true, // Overlay scrollbars should clip to rounded borders
863    scroll_behavior: ScrollBehavior::Smooth,
864    overscroll_behavior_x: OverscrollBehavior::Contain,
865    overscroll_behavior_y: OverscrollBehavior::Auto,
866    overflow_scrolling: OverflowScrolling::Auto,
867};
868
869/// A modern, thin, overlay scrollbar inspired by Android (Dark Theme).
870pub const SCROLLBAR_ANDROID_DARK: ScrollbarInfo = ScrollbarInfo {
871    width: LayoutWidth::Px(crate::props::basic::pixel::PixelValue::const_px(6)),
872    padding_left: LayoutPaddingLeft {
873        inner: crate::props::basic::pixel::PixelValue::const_px(0),
874    },
875    padding_right: LayoutPaddingRight {
876        inner: crate::props::basic::pixel::PixelValue::const_px(0),
877    },
878    track: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
879    thumb: StyleBackgroundContent::Color(ColorU {
880        r: 255,
881        g: 255,
882        b: 255,
883        a: 102,
884    }), // rgba(255,255,255,0.4)
885    button: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
886    corner: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
887    resizer: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
888    clip_to_container_border: true, // Overlay scrollbars should clip to rounded borders
889    scroll_behavior: ScrollBehavior::Smooth,
890    overscroll_behavior_x: OverscrollBehavior::Contain,
891    overscroll_behavior_y: OverscrollBehavior::Auto,
892    overflow_scrolling: OverflowScrolling::Auto,
893};
894
895// --- PARSERS ---
896
897#[derive(Clone, PartialEq, Eq)]
898pub enum LayoutScrollbarWidthParseError<'a> {
899    InvalidValue(&'a str),
900}
901impl_debug_as_display!(LayoutScrollbarWidthParseError<'a>);
902impl_display! { LayoutScrollbarWidthParseError<'a>, {
903    InvalidValue(v) => format!("Invalid scrollbar-width value: \"{}\"", v),
904}}
905
906#[derive(Debug, Clone, PartialEq, Eq)]
907#[repr(C, u8)]
908pub enum LayoutScrollbarWidthParseErrorOwned {
909    InvalidValue(AzString),
910}
911impl LayoutScrollbarWidthParseError<'_> {
912    #[must_use] pub fn to_contained(&self) -> LayoutScrollbarWidthParseErrorOwned {
913        match self {
914            Self::InvalidValue(s) => {
915                LayoutScrollbarWidthParseErrorOwned::InvalidValue((*s).to_string().into())
916            }
917        }
918    }
919}
920impl LayoutScrollbarWidthParseErrorOwned {
921    #[must_use] pub fn to_shared(&self) -> LayoutScrollbarWidthParseError<'_> {
922        match self {
923            Self::InvalidValue(s) => LayoutScrollbarWidthParseError::InvalidValue(s.as_str()),
924        }
925    }
926}
927
928#[cfg(feature = "parser")]
929/// # Errors
930///
931/// Returns an error if `input` is not a valid CSS `scrollbar-width` value.
932pub fn parse_layout_scrollbar_width(
933    input: &str,
934) -> Result<LayoutScrollbarWidth, LayoutScrollbarWidthParseError<'_>> {
935    match input.trim() {
936        "auto" => Ok(LayoutScrollbarWidth::Auto),
937        "thin" => Ok(LayoutScrollbarWidth::Thin),
938        "none" => Ok(LayoutScrollbarWidth::None),
939        _ => Err(LayoutScrollbarWidthParseError::InvalidValue(input)),
940    }
941}
942
943#[derive(Clone, PartialEq)]
944pub enum StyleScrollbarColorParseError<'a> {
945    InvalidValue(&'a str),
946    Color(CssColorParseError<'a>),
947}
948impl_debug_as_display!(StyleScrollbarColorParseError<'a>);
949impl_display! { StyleScrollbarColorParseError<'a>, {
950    InvalidValue(v) => format!("Invalid scrollbar-color value: \"{}\"", v),
951    Color(e) => format!("Invalid color in scrollbar-color: {}", e),
952}}
953impl_from!(CssColorParseError<'a>, StyleScrollbarColorParseError::Color);
954
955#[derive(Debug, Clone, PartialEq)]
956#[repr(C, u8)]
957pub enum StyleScrollbarColorParseErrorOwned {
958    InvalidValue(AzString),
959    Color(CssColorParseErrorOwned),
960}
961impl StyleScrollbarColorParseError<'_> {
962    #[must_use] pub fn to_contained(&self) -> StyleScrollbarColorParseErrorOwned {
963        match self {
964            Self::InvalidValue(s) => {
965                StyleScrollbarColorParseErrorOwned::InvalidValue((*s).to_string().into())
966            }
967            Self::Color(e) => StyleScrollbarColorParseErrorOwned::Color(e.to_contained()),
968        }
969    }
970}
971impl StyleScrollbarColorParseErrorOwned {
972    #[must_use] pub fn to_shared(&self) -> StyleScrollbarColorParseError<'_> {
973        match self {
974            Self::InvalidValue(s) => StyleScrollbarColorParseError::InvalidValue(s.as_str()),
975            Self::Color(e) => StyleScrollbarColorParseError::Color(e.to_shared()),
976        }
977    }
978}
979
980#[cfg(feature = "parser")]
981/// # Errors
982///
983/// Returns an error if `input` is not a valid CSS `scrollbar-color` value.
984pub fn parse_style_scrollbar_color(
985    input: &str,
986) -> Result<StyleScrollbarColor, StyleScrollbarColorParseError<'_>> {
987    let input = input.trim();
988    if input == "auto" {
989        return Ok(StyleScrollbarColor::Auto);
990    }
991
992    let mut parts = input.split_whitespace();
993    let thumb_str = parts
994        .next()
995        .ok_or(StyleScrollbarColorParseError::InvalidValue(input))?;
996    let track_str = parts
997        .next()
998        .ok_or(StyleScrollbarColorParseError::InvalidValue(input))?;
999
1000    if parts.next().is_some() {
1001        return Err(StyleScrollbarColorParseError::InvalidValue(input));
1002    }
1003
1004    let thumb = parse_css_color(thumb_str)?;
1005    let track = parse_css_color(track_str)?;
1006
1007    Ok(StyleScrollbarColor::Custom(ScrollbarColorCustom {
1008        thumb,
1009        track,
1010    }))
1011}
1012
1013// --- Scrollbar Visibility Mode Parser ---
1014
1015#[derive(Clone, PartialEq, Eq)]
1016pub enum ScrollbarVisibilityModeParseError<'a> {
1017    InvalidValue(&'a str),
1018}
1019impl_debug_as_display!(ScrollbarVisibilityModeParseError<'a>);
1020impl_display! { ScrollbarVisibilityModeParseError<'a>, {
1021    InvalidValue(v) => format!("Invalid scrollbar-visibility value: \"{}\"", v),
1022}}
1023
1024#[derive(Debug, Clone, PartialEq, Eq)]
1025#[repr(C, u8)]
1026pub enum ScrollbarVisibilityModeParseErrorOwned {
1027    InvalidValue(AzString),
1028}
1029impl ScrollbarVisibilityModeParseError<'_> {
1030    #[must_use] pub fn to_contained(&self) -> ScrollbarVisibilityModeParseErrorOwned {
1031        match self {
1032            Self::InvalidValue(s) => ScrollbarVisibilityModeParseErrorOwned::InvalidValue((*s).to_string().into()),
1033        }
1034    }
1035}
1036impl ScrollbarVisibilityModeParseErrorOwned {
1037    #[must_use] pub fn to_shared(&self) -> ScrollbarVisibilityModeParseError<'_> {
1038        match self {
1039            Self::InvalidValue(s) => ScrollbarVisibilityModeParseError::InvalidValue(s.as_str()),
1040        }
1041    }
1042}
1043
1044#[cfg(feature = "parser")]
1045/// # Errors
1046///
1047/// Returns an error if `input` is not a valid CSS `scrollbar-visibility-mode` value.
1048pub fn parse_scrollbar_visibility_mode(
1049    input: &str,
1050) -> Result<ScrollbarVisibilityMode, ScrollbarVisibilityModeParseError<'_>> {
1051    match input.trim() {
1052        "always" => Ok(ScrollbarVisibilityMode::Always),
1053        "when-scrolling" => Ok(ScrollbarVisibilityMode::WhenScrolling),
1054        "auto" => Ok(ScrollbarVisibilityMode::Auto),
1055        _ => Err(ScrollbarVisibilityModeParseError::InvalidValue(input)),
1056    }
1057}
1058
1059// --- Scrollbar Fade Delay Parser ---
1060
1061#[derive(Clone, PartialEq, Eq)]
1062pub enum ScrollbarFadeDelayParseError<'a> {
1063    InvalidValue(&'a str),
1064}
1065impl_debug_as_display!(ScrollbarFadeDelayParseError<'a>);
1066impl_display! { ScrollbarFadeDelayParseError<'a>, {
1067    InvalidValue(v) => format!("Invalid scrollbar-fade-delay value: \"{}\"", v),
1068}}
1069
1070#[derive(Debug, Clone, PartialEq, Eq)]
1071#[repr(C, u8)]
1072pub enum ScrollbarFadeDelayParseErrorOwned {
1073    InvalidValue(AzString),
1074}
1075impl ScrollbarFadeDelayParseError<'_> {
1076    #[must_use] pub fn to_contained(&self) -> ScrollbarFadeDelayParseErrorOwned {
1077        match self {
1078            Self::InvalidValue(s) => ScrollbarFadeDelayParseErrorOwned::InvalidValue((*s).to_string().into()),
1079        }
1080    }
1081}
1082impl ScrollbarFadeDelayParseErrorOwned {
1083    #[must_use] pub fn to_shared(&self) -> ScrollbarFadeDelayParseError<'_> {
1084        match self {
1085            Self::InvalidValue(s) => ScrollbarFadeDelayParseError::InvalidValue(s.as_str()),
1086        }
1087    }
1088}
1089
1090/// `scrollbar-fade-delay` / `scrollbar-fade-duration` are stored as a plain
1091/// millisecond `u32`, so a `t` (tick) value has to be converted here rather than
1092/// carried. `CssDuration::millis` does that at the nominal frame rate — reading
1093/// `d.inner` directly would hand a FRAME COUNT to a field every consumer reads as
1094/// milliseconds (`5t` would silently become 5ms, a 16x error).
1095#[cfg(feature = "parser")]
1096fn parse_time_ms(input: &str) -> Option<u32> {
1097    crate::props::basic::time::parse_duration(input).ok().map(|d| d.millis())
1098}
1099
1100#[cfg(feature = "parser")]
1101/// # Errors
1102///
1103/// Returns an error if `input` is not a valid CSS `scrollbar-fade-delay` value.
1104pub fn parse_scrollbar_fade_delay(
1105    input: &str,
1106) -> Result<ScrollbarFadeDelay, ScrollbarFadeDelayParseError<'_>> {
1107    parse_time_ms(input)
1108        .map(ScrollbarFadeDelay::new)
1109        .ok_or(ScrollbarFadeDelayParseError::InvalidValue(input))
1110}
1111
1112// --- Scrollbar Fade Duration Parser ---
1113
1114#[derive(Clone, PartialEq, Eq)]
1115pub enum ScrollbarFadeDurationParseError<'a> {
1116    InvalidValue(&'a str),
1117}
1118impl_debug_as_display!(ScrollbarFadeDurationParseError<'a>);
1119impl_display! { ScrollbarFadeDurationParseError<'a>, {
1120    InvalidValue(v) => format!("Invalid scrollbar-fade-duration value: \"{}\"", v),
1121}}
1122
1123#[derive(Debug, Clone, PartialEq, Eq)]
1124#[repr(C, u8)]
1125pub enum ScrollbarFadeDurationParseErrorOwned {
1126    InvalidValue(AzString),
1127}
1128impl ScrollbarFadeDurationParseError<'_> {
1129    #[must_use] pub fn to_contained(&self) -> ScrollbarFadeDurationParseErrorOwned {
1130        match self {
1131            Self::InvalidValue(s) => ScrollbarFadeDurationParseErrorOwned::InvalidValue((*s).to_string().into()),
1132        }
1133    }
1134}
1135impl ScrollbarFadeDurationParseErrorOwned {
1136    #[must_use] pub fn to_shared(&self) -> ScrollbarFadeDurationParseError<'_> {
1137        match self {
1138            Self::InvalidValue(s) => ScrollbarFadeDurationParseError::InvalidValue(s.as_str()),
1139        }
1140    }
1141}
1142
1143#[cfg(feature = "parser")]
1144/// # Errors
1145///
1146/// Returns an error if `input` is not a valid CSS `scrollbar-fade-duration` value.
1147pub fn parse_scrollbar_fade_duration(
1148    input: &str,
1149) -> Result<ScrollbarFadeDuration, ScrollbarFadeDurationParseError<'_>> {
1150    parse_time_ms(input)
1151        .map(ScrollbarFadeDuration::new)
1152        .ok_or(ScrollbarFadeDurationParseError::InvalidValue(input))
1153}
1154
1155#[cfg(all(test, feature = "parser"))]
1156mod tests {
1157    use super::*;
1158    use crate::props::basic::color::ColorU;
1159
1160    #[test]
1161    fn test_parse_scrollbar_width() {
1162        assert_eq!(
1163            parse_layout_scrollbar_width("auto").unwrap(),
1164            LayoutScrollbarWidth::Auto
1165        );
1166        assert_eq!(
1167            parse_layout_scrollbar_width("thin").unwrap(),
1168            LayoutScrollbarWidth::Thin
1169        );
1170        assert_eq!(
1171            parse_layout_scrollbar_width("none").unwrap(),
1172            LayoutScrollbarWidth::None
1173        );
1174        assert!(parse_layout_scrollbar_width("thick").is_err());
1175    }
1176
1177    #[test]
1178    fn test_parse_scrollbar_color() {
1179        assert_eq!(
1180            parse_style_scrollbar_color("auto").unwrap(),
1181            StyleScrollbarColor::Auto
1182        );
1183
1184        let custom = parse_style_scrollbar_color("red blue").unwrap();
1185        assert_eq!(
1186            custom,
1187            StyleScrollbarColor::Custom(ScrollbarColorCustom {
1188                thumb: ColorU::RED,
1189                track: ColorU::BLUE
1190            })
1191        );
1192
1193        let custom_hex = parse_style_scrollbar_color("#ff0000 #0000ff").unwrap();
1194        assert_eq!(
1195            custom_hex,
1196            StyleScrollbarColor::Custom(ScrollbarColorCustom {
1197                thumb: ColorU::RED,
1198                track: ColorU::BLUE
1199            })
1200        );
1201
1202        assert!(parse_style_scrollbar_color("red").is_err());
1203        assert!(parse_style_scrollbar_color("red blue green").is_err());
1204    }
1205}
1206
1207#[cfg(test)]
1208#[allow(clippy::unreadable_literal, clippy::float_cmp)]
1209mod autotest_generated {
1210    use super::*;
1211    use crate::codegen::format::FormatAsRustCode;
1212
1213    /// Largest integer an `f32` represents exactly (`2^24`). Every millisecond
1214    /// count at or below this survives the `f32` hop inside `parse_duration`;
1215    /// above it, neighbouring `f32`s are more than 1ms apart.
1216    #[cfg(feature = "parser")]
1217    const TWO_POW_24: u32 = 16_777_216;
1218
1219    /// Inputs that must never parse as anything, whatever the property.
1220    #[cfg(feature = "parser")]
1221    const GARBAGE: &[&str] = &[
1222        "",
1223        " ",
1224        "   ",
1225        "\t\n",
1226        "\u{a0}",          // non-breaking space (trimmed away -> empty)
1227        "\0",
1228        "\u{1F600}",       // emoji
1229        "e\u{0301}",       // combining acute accent
1230        "\u{202e}auto",    // RTL override prefix
1231        "аuto",            // Cyrillic 'а' homoglyph
1232        "AUTO",
1233        "auto;",
1234        "auto garbage",
1235        "-1",
1236        "NaN",
1237        "inf",
1238        "0x10",
1239        "9223372036854775807", // i64::MAX
1240        "1e400",
1241        "{[(<",
1242    ];
1243
1244    // ======================================================================
1245    // ScrollPhysics presets  (other)
1246    // ======================================================================
1247
1248    /// Every preset must be usable as-is by a scroll animator: no NaN/inf can
1249    /// reach the physics integrator, the deceleration rate has to stay strictly
1250    /// below 1.0 (at 1.0 momentum never decays -> the scroll timer never stops),
1251    /// and the timer tick must be non-zero (a 0ms tick is a busy-loop).
1252    fn assert_physics_invariants(p: ScrollPhysics, name: &str) {
1253        for (field, v) in [
1254            ("deceleration_rate", p.deceleration_rate),
1255            ("min_velocity_threshold", p.min_velocity_threshold),
1256            ("max_velocity", p.max_velocity),
1257            ("wheel_multiplier", p.wheel_multiplier),
1258            ("overscroll_elasticity", p.overscroll_elasticity),
1259            ("max_overscroll_distance", p.max_overscroll_distance),
1260        ] {
1261            assert!(v.is_finite(), "{name}.{field} is not finite: {v}");
1262            assert!(!v.is_nan(), "{name}.{field} is NaN");
1263            assert!(v >= 0.0, "{name}.{field} is negative: {v}");
1264        }
1265
1266        assert!(
1267            p.deceleration_rate > 0.0 && p.deceleration_rate < 1.0,
1268            "{name}.deceleration_rate must stay inside (0.0, 1.0) or momentum never stops: {}",
1269            p.deceleration_rate
1270        );
1271        assert!(
1272            (0.0..=1.0).contains(&p.overscroll_elasticity),
1273            "{name}.overscroll_elasticity out of [0.0, 1.0]: {}",
1274            p.overscroll_elasticity
1275        );
1276        assert!(
1277            p.max_velocity > p.min_velocity_threshold,
1278            "{name}: max_velocity ({}) must exceed min_velocity_threshold ({})",
1279            p.max_velocity,
1280            p.min_velocity_threshold
1281        );
1282        assert!(
1283            p.wheel_multiplier > 0.0,
1284            "{name}.wheel_multiplier must be > 0 or the wheel does nothing"
1285        );
1286        assert!(
1287            p.timer_interval_ms > 0,
1288            "{name}.timer_interval_ms == 0 would spin the physics timer"
1289        );
1290        assert!(
1291            p.smooth_scroll_duration_ms > 0,
1292            "{name}.smooth_scroll_duration_ms == 0 makes `scroll-behavior: smooth` a no-op"
1293        );
1294    }
1295
1296    #[test]
1297    fn scroll_physics_presets_hold_their_invariants() {
1298        assert_physics_invariants(ScrollPhysics::default(), "default");
1299        assert_physics_invariants(ScrollPhysics::ios(), "ios");
1300        assert_physics_invariants(ScrollPhysics::macos(), "macos");
1301        assert_physics_invariants(ScrollPhysics::windows(), "windows");
1302        assert_physics_invariants(ScrollPhysics::android(), "android");
1303    }
1304
1305    #[test]
1306    fn scroll_physics_presets_are_pure_and_distinct() {
1307        // Called twice: no interior state, no drift.
1308        assert_eq!(ScrollPhysics::ios(), ScrollPhysics::ios());
1309        assert_eq!(ScrollPhysics::windows(), ScrollPhysics::windows());
1310
1311        // A preset that silently equals another would mean a copy/paste bug.
1312        assert_ne!(ScrollPhysics::ios(), ScrollPhysics::macos());
1313        assert_ne!(ScrollPhysics::ios(), ScrollPhysics::android());
1314        assert_ne!(ScrollPhysics::macos(), ScrollPhysics::windows());
1315        assert_ne!(ScrollPhysics::android(), ScrollPhysics::windows());
1316        assert_ne!(ScrollPhysics::default(), ScrollPhysics::ios());
1317    }
1318
1319    /// The documented platform character of each preset, asserted rather than
1320    /// assumed: Windows must not bounce, iOS/macOS must scroll naturally.
1321    #[test]
1322    fn scroll_physics_presets_match_their_documented_platform_behavior() {
1323        let win = ScrollPhysics::windows();
1324        assert_eq!(win.overscroll_elasticity, 0.0);
1325        assert_eq!(win.max_overscroll_distance, 0.0);
1326        assert!(!win.invert_direction);
1327
1328        assert!(ScrollPhysics::ios().invert_direction);
1329        assert!(ScrollPhysics::macos().invert_direction);
1330        assert!(!ScrollPhysics::android().invert_direction);
1331
1332        // iOS is the "slowest to stop" of the presets.
1333        assert!(ScrollPhysics::ios().deceleration_rate > ScrollPhysics::windows().deceleration_rate);
1334
1335        // The default is Windows-like: no bounce.
1336        assert_eq!(ScrollPhysics::default().overscroll_elasticity, 0.0);
1337    }
1338
1339    // ======================================================================
1340    // ScrollbarFadeDelay::new / ScrollbarFadeDuration::new  (constructors)
1341    // ======================================================================
1342
1343    #[test]
1344    fn fade_delay_and_duration_constructors_store_their_argument_verbatim() {
1345        for ms in [0u32, 1, 16, 500, u32::MAX / 2, u32::MAX - 1, u32::MAX] {
1346            assert_eq!(ScrollbarFadeDelay::new(ms).ms, ms);
1347            assert_eq!(ScrollbarFadeDuration::new(ms).ms, ms);
1348        }
1349    }
1350
1351    #[test]
1352    fn fade_zero_constants_agree_with_new_and_default() {
1353        assert_eq!(ScrollbarFadeDelay::ZERO, ScrollbarFadeDelay::new(0));
1354        assert_eq!(ScrollbarFadeDelay::ZERO, ScrollbarFadeDelay::default());
1355        assert_eq!(ScrollbarFadeDuration::ZERO, ScrollbarFadeDuration::new(0));
1356        assert_eq!(ScrollbarFadeDuration::ZERO, ScrollbarFadeDuration::default());
1357        assert_eq!(ScrollbarFadeDelay::ZERO.ms, 0);
1358        assert_eq!(ScrollbarFadeDuration::ZERO.ms, 0);
1359    }
1360
1361    /// The derived `Ord` must order by milliseconds, not by declaration order of
1362    /// some future field, otherwise "fades sooner" comparisons invert.
1363    #[test]
1364    fn fade_delay_orders_by_millisecond_count() {
1365        assert!(ScrollbarFadeDelay::new(0) < ScrollbarFadeDelay::new(1));
1366        assert!(ScrollbarFadeDelay::new(499) < ScrollbarFadeDelay::new(500));
1367        assert!(ScrollbarFadeDelay::new(u32::MAX) > ScrollbarFadeDelay::new(u32::MAX - 1));
1368        assert!(ScrollbarFadeDuration::new(0) < ScrollbarFadeDuration::new(u32::MAX));
1369    }
1370
1371    // ======================================================================
1372    // print_as_css_value  (encoders)
1373    // ======================================================================
1374
1375    #[test]
1376    fn enum_printers_emit_the_css_keywords() {
1377        assert_eq!(ScrollBehavior::Auto.print_as_css_value(), "auto");
1378        assert_eq!(ScrollBehavior::Smooth.print_as_css_value(), "smooth");
1379        assert_eq!(ScrollBehavior::default(), ScrollBehavior::Auto);
1380
1381        assert_eq!(OverscrollBehavior::Auto.print_as_css_value(), "auto");
1382        assert_eq!(OverscrollBehavior::Contain.print_as_css_value(), "contain");
1383        assert_eq!(OverscrollBehavior::None.print_as_css_value(), "none");
1384        assert_eq!(OverscrollBehavior::default(), OverscrollBehavior::Auto);
1385
1386        assert_eq!(OverflowScrolling::Auto.print_as_css_value(), "auto");
1387        assert_eq!(OverflowScrolling::Touch.print_as_css_value(), "touch");
1388        assert_eq!(OverflowScrolling::default(), OverflowScrolling::Auto);
1389
1390        assert_eq!(LayoutScrollbarWidth::Auto.print_as_css_value(), "auto");
1391        assert_eq!(LayoutScrollbarWidth::Thin.print_as_css_value(), "thin");
1392        assert_eq!(LayoutScrollbarWidth::None.print_as_css_value(), "none");
1393        assert_eq!(LayoutScrollbarWidth::default(), LayoutScrollbarWidth::Auto);
1394
1395        assert_eq!(ScrollbarVisibilityMode::Always.print_as_css_value(), "always");
1396        assert_eq!(
1397            ScrollbarVisibilityMode::WhenScrolling.print_as_css_value(),
1398            "when-scrolling"
1399        );
1400        assert_eq!(ScrollbarVisibilityMode::Auto.print_as_css_value(), "auto");
1401        assert_eq!(
1402            ScrollbarVisibilityMode::default(),
1403            ScrollbarVisibilityMode::Always
1404        );
1405    }
1406
1407    /// `0` is printed unit-less (a bare `0` is legal CSS for a time), everything
1408    /// else carries the `ms` unit — dropping the unit on a non-zero value would
1409    /// emit invalid CSS.
1410    #[test]
1411    fn fade_printers_special_case_zero_and_keep_the_unit_otherwise() {
1412        assert_eq!(ScrollbarFadeDelay::new(0).print_as_css_value(), "0");
1413        assert_eq!(ScrollbarFadeDelay::new(1).print_as_css_value(), "1ms");
1414        assert_eq!(ScrollbarFadeDelay::new(500).print_as_css_value(), "500ms");
1415        assert_eq!(
1416            ScrollbarFadeDelay::new(u32::MAX).print_as_css_value(),
1417            "4294967295ms"
1418        );
1419        assert_eq!(ScrollbarFadeDuration::new(0).print_as_css_value(), "0");
1420        assert_eq!(ScrollbarFadeDuration::new(200).print_as_css_value(), "200ms");
1421        assert_eq!(
1422            ScrollbarFadeDuration::new(u32::MAX).print_as_css_value(),
1423            "4294967295ms"
1424        );
1425    }
1426
1427    #[test]
1428    fn scrollbar_color_printer_emits_two_eight_digit_hashes() {
1429        assert_eq!(StyleScrollbarColor::Auto.print_as_css_value(), "auto");
1430        assert_eq!(StyleScrollbarColor::default(), StyleScrollbarColor::Auto);
1431
1432        let custom = StyleScrollbarColor::Custom(ScrollbarColorCustom {
1433            thumb: ColorU::RED,
1434            track: ColorU::TRANSPARENT,
1435        });
1436        assert_eq!(custom.print_as_css_value(), "#ff0000ff #00000000");
1437    }
1438
1439    /// The aggregate printers are non-standard debug formats; they must at least
1440    /// not panic and must include both sub-scrollbars.
1441    #[test]
1442    fn aggregate_printers_do_not_panic_and_mention_both_axes() {
1443        let printed = ScrollbarStyle::default().print_as_css_value();
1444        assert!(printed.contains("horz("), "{printed}");
1445        assert!(printed.contains("vert("), "{printed}");
1446
1447        let info = ScrollbarInfo::default().print_as_css_value();
1448        assert!(info.contains("width:"), "{info}");
1449        assert!(info.contains("thumb:"), "{info}");
1450        assert!(info.contains("resizer:"), "{info}");
1451    }
1452
1453    // ======================================================================
1454    // FormatAsRustCode  (codegen encoders)
1455    // ======================================================================
1456
1457    #[test]
1458    fn format_as_rust_code_emits_constructible_expressions() {
1459        assert_eq!(
1460            LayoutScrollbarWidth::Thin.format_as_rust_code(0),
1461            "LayoutScrollbarWidth::Thin"
1462        );
1463        assert_eq!(
1464            LayoutScrollbarWidth::None.format_as_rust_code(7),
1465            "LayoutScrollbarWidth::None",
1466            "indent depth must not leak into a unit-variant literal"
1467        );
1468        assert_eq!(
1469            ScrollbarVisibilityMode::WhenScrolling.format_as_rust_code(0),
1470            "ScrollbarVisibilityMode::WhenScrolling"
1471        );
1472        assert_eq!(
1473            StyleScrollbarColor::Auto.format_as_rust_code(0),
1474            "StyleScrollbarColor::Auto"
1475        );
1476
1477        // The `new(..)` codegen must round-trip the exact u32, including the extremes.
1478        assert_eq!(
1479            ScrollbarFadeDelay::new(0).format_as_rust_code(0),
1480            "ScrollbarFadeDelay::new(0)"
1481        );
1482        assert_eq!(
1483            ScrollbarFadeDelay::new(u32::MAX).format_as_rust_code(3),
1484            "ScrollbarFadeDelay::new(4294967295)"
1485        );
1486        assert_eq!(
1487            ScrollbarFadeDuration::new(u32::MAX).format_as_rust_code(0),
1488            "ScrollbarFadeDuration::new(4294967295)"
1489        );
1490    }
1491
1492    #[test]
1493    fn format_as_rust_code_of_aggregates_does_not_panic() {
1494        let custom = StyleScrollbarColor::Custom(ScrollbarColorCustom {
1495            thumb: ColorU::TRANSPARENT,
1496            track: ColorU::WHITE,
1497        })
1498        .format_as_rust_code(0);
1499        assert!(
1500            custom.starts_with("StyleScrollbarColor::Custom(ScrollbarColorCustom {"),
1501            "{custom}"
1502        );
1503        assert!(custom.contains("thumb:") && custom.contains("track:"), "{custom}");
1504
1505        for tabs in [0usize, 1, 4] {
1506            let code = ScrollbarStyle::default().format_as_rust_code(tabs);
1507            assert!(code.starts_with("ScrollbarStyle {"), "{code}");
1508            assert!(code.contains("horizontal:"), "{code}");
1509            assert!(code.contains("vertical:"), "{code}");
1510        }
1511    }
1512
1513    // ======================================================================
1514    // Defaults / constants  (invariants)
1515    // ======================================================================
1516
1517    #[test]
1518    fn scrollbar_info_default_is_the_classic_light_constant() {
1519        assert_eq!(ScrollbarInfo::default(), SCROLLBAR_CLASSIC_LIGHT);
1520
1521        let style = ScrollbarStyle::default();
1522        assert_eq!(style.horizontal, SCROLLBAR_CLASSIC_LIGHT);
1523        assert_eq!(style.vertical, SCROLLBAR_CLASSIC_LIGHT);
1524    }
1525
1526    /// `ComputedScrollbarStyle::default()` reads its colors out of the default
1527    /// `ScrollbarInfo`; if the default track/thumb ever became a gradient the
1528    /// `match` would silently fall through to `None` (UA default) instead.
1529    #[test]
1530    fn computed_default_mirrors_the_default_scrollbar_info() {
1531        let computed = ComputedScrollbarStyle::default();
1532        let info = ScrollbarInfo::default();
1533
1534        assert_eq!(computed.width, Some(info.width));
1535        assert_eq!(
1536            computed.thumb_color,
1537            Some(ColorU { r: 193, g: 193, b: 193, a: 255 })
1538        );
1539        assert_eq!(
1540            computed.track_color,
1541            Some(ColorU { r: 241, g: 241, b: 241, a: 255 })
1542        );
1543        assert!(
1544            computed.thumb_color.is_some() && computed.track_color.is_some(),
1545            "the classic-light default must resolve to solid colors, not None"
1546        );
1547    }
1548
1549    /// Overlay presets must clip to the container border, classic (space-reserving)
1550    /// ones must not — the flag is what decides whether the bar is drawn inside
1551    /// rounded corners.
1552    #[test]
1553    fn preset_constants_agree_on_the_overlay_clipping_flag() {
1554        for info in [SCROLLBAR_CLASSIC_LIGHT, SCROLLBAR_CLASSIC_DARK] {
1555            assert!(!info.clip_to_container_border);
1556            assert_eq!(info.scroll_behavior, ScrollBehavior::Auto);
1557        }
1558        for info in [
1559            SCROLLBAR_MACOS_LIGHT,
1560            SCROLLBAR_MACOS_DARK,
1561            SCROLLBAR_IOS_LIGHT,
1562            SCROLLBAR_IOS_DARK,
1563            SCROLLBAR_ANDROID_LIGHT,
1564            SCROLLBAR_ANDROID_DARK,
1565        ] {
1566            assert!(info.clip_to_container_border);
1567            assert_eq!(info.scroll_behavior, ScrollBehavior::Smooth);
1568        }
1569        for info in [SCROLLBAR_WINDOWS_LIGHT, SCROLLBAR_WINDOWS_DARK] {
1570            assert!(!info.clip_to_container_border);
1571            assert_eq!(info.overscroll_behavior_x, OverscrollBehavior::None);
1572            assert_eq!(info.overscroll_behavior_y, OverscrollBehavior::None);
1573        }
1574    }
1575
1576    /// Light and dark variants of the same platform must differ only in color,
1577    /// never in geometry — a width drift would make theme switching relayout.
1578    #[test]
1579    fn light_and_dark_presets_share_their_geometry() {
1580        for (light, dark) in [
1581            (SCROLLBAR_CLASSIC_LIGHT, SCROLLBAR_CLASSIC_DARK),
1582            (SCROLLBAR_MACOS_LIGHT, SCROLLBAR_MACOS_DARK),
1583            (SCROLLBAR_WINDOWS_LIGHT, SCROLLBAR_WINDOWS_DARK),
1584            (SCROLLBAR_IOS_LIGHT, SCROLLBAR_IOS_DARK),
1585            (SCROLLBAR_ANDROID_LIGHT, SCROLLBAR_ANDROID_DARK),
1586        ] {
1587            assert_eq!(light.width, dark.width);
1588            assert_eq!(light.padding_left, dark.padding_left);
1589            assert_eq!(light.padding_right, dark.padding_right);
1590            assert_eq!(light.clip_to_container_border, dark.clip_to_container_border);
1591            assert_ne!(light.thumb, dark.thumb, "light/dark thumbs must differ");
1592        }
1593    }
1594
1595    // ======================================================================
1596    // parse_layout_scrollbar_width  (parser)
1597    // ======================================================================
1598
1599    #[cfg(feature = "parser")]
1600    #[test]
1601    fn scrollbar_width_parses_the_three_legal_keywords() {
1602        assert_eq!(
1603            parse_layout_scrollbar_width("auto"),
1604            Ok(LayoutScrollbarWidth::Auto)
1605        );
1606        assert_eq!(
1607            parse_layout_scrollbar_width("thin"),
1608            Ok(LayoutScrollbarWidth::Thin)
1609        );
1610        assert_eq!(
1611            parse_layout_scrollbar_width("none"),
1612            Ok(LayoutScrollbarWidth::None)
1613        );
1614    }
1615
1616    #[cfg(feature = "parser")]
1617    #[test]
1618    fn scrollbar_width_trims_surrounding_whitespace_but_rejects_inner_junk() {
1619        assert_eq!(
1620            parse_layout_scrollbar_width("  \t thin \n "),
1621            Ok(LayoutScrollbarWidth::Thin)
1622        );
1623        assert!(parse_layout_scrollbar_width("thin;").is_err());
1624        assert!(parse_layout_scrollbar_width("thin thin").is_err());
1625        assert!(parse_layout_scrollbar_width("th in").is_err());
1626    }
1627
1628    /// Keyword matching is byte-exact: CSS keywords are case-insensitive in the
1629    /// spec, so an upper-case `AUTO` being rejected here is a real (if minor)
1630    /// conformance gap. Pinned so a future fix is a deliberate change.
1631    #[cfg(feature = "parser")]
1632    #[test]
1633    fn scrollbar_width_keyword_matching_is_case_sensitive() {
1634        assert!(parse_layout_scrollbar_width("AUTO").is_err());
1635        assert!(parse_layout_scrollbar_width("Thin").is_err());
1636        assert!(parse_layout_scrollbar_width("NONE").is_err());
1637    }
1638
1639    #[cfg(feature = "parser")]
1640    #[test]
1641    fn scrollbar_width_rejects_every_garbage_input_without_panicking() {
1642        for input in GARBAGE {
1643            assert!(
1644                parse_layout_scrollbar_width(input).is_err(),
1645                "expected {input:?} to be rejected"
1646            );
1647        }
1648    }
1649
1650    /// The error must carry the caller's *untrimmed* slice so diagnostics can
1651    /// point back at the original source text.
1652    #[cfg(feature = "parser")]
1653    #[test]
1654    fn scrollbar_width_error_keeps_the_raw_untrimmed_input() {
1655        let raw = "  thick  ";
1656        assert_eq!(
1657            parse_layout_scrollbar_width(raw),
1658            Err(LayoutScrollbarWidthParseError::InvalidValue(raw))
1659        );
1660        let msg = format!("{}", parse_layout_scrollbar_width(raw).unwrap_err());
1661        assert!(msg.contains(raw), "{msg}");
1662    }
1663
1664    #[cfg(feature = "parser")]
1665    #[test]
1666    fn scrollbar_width_survives_a_megabyte_of_input_and_deep_nesting() {
1667        let huge = "a".repeat(1_000_000);
1668        assert!(parse_layout_scrollbar_width(&huge).is_err());
1669
1670        let repeated_token = "auto".repeat(250_000);
1671        assert!(parse_layout_scrollbar_width(&repeated_token).is_err());
1672
1673        let nested = "(".repeat(10_000);
1674        assert!(parse_layout_scrollbar_width(&nested).is_err());
1675    }
1676
1677    #[cfg(feature = "parser")]
1678    #[test]
1679    fn scrollbar_width_round_trips_through_its_printer() {
1680        for value in [
1681            LayoutScrollbarWidth::Auto,
1682            LayoutScrollbarWidth::Thin,
1683            LayoutScrollbarWidth::None,
1684        ] {
1685            let encoded = value.print_as_css_value();
1686            assert_eq!(
1687                parse_layout_scrollbar_width(&encoded),
1688                Ok(value),
1689                "{encoded} did not decode back to {value:?}"
1690            );
1691        }
1692    }
1693
1694    // ======================================================================
1695    // parse_style_scrollbar_color  (parser)
1696    // ======================================================================
1697
1698    #[cfg(feature = "parser")]
1699    #[test]
1700    fn scrollbar_color_needs_exactly_two_colors_or_the_auto_keyword() {
1701        assert_eq!(parse_style_scrollbar_color("auto"), Ok(StyleScrollbarColor::Auto));
1702        assert_eq!(
1703            parse_style_scrollbar_color("  auto  "),
1704            Ok(StyleScrollbarColor::Auto)
1705        );
1706        assert_eq!(
1707            parse_style_scrollbar_color("red blue"),
1708            Ok(StyleScrollbarColor::Custom(ScrollbarColorCustom {
1709                thumb: ColorU::RED,
1710                track: ColorU::BLUE,
1711            }))
1712        );
1713
1714        // Too few / too many components: rejected as InvalidValue, not as a color error.
1715        for input in ["red", "#fff", "red blue green", "a b c d"] {
1716            assert!(
1717                matches!(
1718                    parse_style_scrollbar_color(input),
1719                    Err(StyleScrollbarColorParseError::InvalidValue(_))
1720                ),
1721                "expected {input:?} to be an InvalidValue error"
1722            );
1723        }
1724    }
1725
1726    /// Component splitting is on *any* whitespace run, so tabs, newlines and
1727    /// repeated spaces are all legal separators.
1728    #[cfg(feature = "parser")]
1729    #[test]
1730    fn scrollbar_color_accepts_any_whitespace_run_as_the_separator() {
1731        let expected = StyleScrollbarColor::Custom(ScrollbarColorCustom {
1732            thumb: ColorU::RED,
1733            track: ColorU::BLUE,
1734        });
1735        assert_eq!(parse_style_scrollbar_color("red\tblue"), Ok(expected));
1736        assert_eq!(parse_style_scrollbar_color("red\n blue"), Ok(expected));
1737        assert_eq!(parse_style_scrollbar_color("  red     blue  "), Ok(expected));
1738    }
1739
1740    /// Color *names* are case-insensitive (the color parser lowercases), unlike
1741    /// the `auto` keyword right above it, which is compared verbatim.
1742    #[cfg(feature = "parser")]
1743    #[test]
1744    fn scrollbar_color_names_are_case_insensitive_but_the_auto_keyword_is_not() {
1745        assert_eq!(
1746            parse_style_scrollbar_color("RED BLUE"),
1747            Ok(StyleScrollbarColor::Custom(ScrollbarColorCustom {
1748                thumb: ColorU::RED,
1749                track: ColorU::BLUE,
1750            }))
1751        );
1752        // "AUTO" is a single token -> not the auto keyword, and not two colors.
1753        assert!(matches!(
1754            parse_style_scrollbar_color("AUTO"),
1755            Err(StyleScrollbarColorParseError::InvalidValue(_))
1756        ));
1757        // ...and `auto` is not a named color either, so it cannot sneak in as one.
1758        assert!(matches!(
1759            parse_style_scrollbar_color("auto auto"),
1760            Err(StyleScrollbarColorParseError::Color(_))
1761        ));
1762    }
1763
1764    /// Whitespace-splitting happens *before* the color parser runs, so a
1765    /// functional color with spaces after its commas is torn into pieces.
1766    /// `rgb(255, 0, 0) blue` is valid CSS but is rejected here; the space-free
1767    /// spelling works. Pinned as a known limitation.
1768    #[cfg(feature = "parser")]
1769    #[test]
1770    fn scrollbar_color_rejects_functional_colors_containing_spaces() {
1771        assert_eq!(
1772            parse_style_scrollbar_color("rgb(255,0,0) blue"),
1773            Ok(StyleScrollbarColor::Custom(ScrollbarColorCustom {
1774                thumb: ColorU::RED,
1775                track: ColorU::BLUE,
1776            }))
1777        );
1778        assert!(matches!(
1779            parse_style_scrollbar_color("rgb(255, 0, 0) blue"),
1780            Err(StyleScrollbarColorParseError::InvalidValue(_))
1781        ));
1782    }
1783
1784    #[cfg(feature = "parser")]
1785    #[test]
1786    fn scrollbar_color_reports_which_component_failed() {
1787        // A bad thumb is reported as a color error, not as InvalidValue.
1788        assert!(matches!(
1789            parse_style_scrollbar_color("notacolor blue"),
1790            Err(StyleScrollbarColorParseError::Color(_))
1791        ));
1792        assert!(matches!(
1793            parse_style_scrollbar_color("red notacolor"),
1794            Err(StyleScrollbarColorParseError::Color(_))
1795        ));
1796        assert!(matches!(
1797            parse_style_scrollbar_color("#gggggg #000000"),
1798            Err(StyleScrollbarColorParseError::Color(_))
1799        ));
1800    }
1801
1802    #[cfg(feature = "parser")]
1803    #[test]
1804    fn scrollbar_color_rejects_garbage_without_panicking() {
1805        for input in GARBAGE {
1806            assert!(
1807                parse_style_scrollbar_color(input).is_err(),
1808                "expected {input:?} to be rejected"
1809            );
1810        }
1811        // Boundary numerics as color components.
1812        for input in [
1813            "0 0",
1814            "-0 -0",
1815            "NaN NaN",
1816            "inf inf",
1817            "9223372036854775807 1",
1818            "1e400 1e400",
1819            "-1 -1",
1820        ] {
1821            assert!(
1822                parse_style_scrollbar_color(input).is_err(),
1823                "expected {input:?} to be rejected"
1824            );
1825        }
1826    }
1827
1828    #[cfg(feature = "parser")]
1829    #[test]
1830    fn scrollbar_color_survives_huge_and_deeply_nested_input() {
1831        let huge = "z".repeat(500_000);
1832        let two_huge = format!("{huge} {huge}");
1833        assert!(parse_style_scrollbar_color(&two_huge).is_err());
1834
1835        let nested = "(".repeat(10_000);
1836        assert!(parse_style_scrollbar_color(&format!("{nested} {nested}")).is_err());
1837
1838        // Many components: must be rejected on count, not walked color-by-color.
1839        let many = "red ".repeat(100_000);
1840        assert!(matches!(
1841            parse_style_scrollbar_color(&many),
1842            Err(StyleScrollbarColorParseError::InvalidValue(_))
1843        ));
1844    }
1845
1846    /// The color error carries the *trimmed* input (the function rebinds `input`
1847    /// to the trimmed slice), unlike `parse_layout_scrollbar_width`, which keeps
1848    /// the raw slice. Pinned so the inconsistency is visible.
1849    #[cfg(feature = "parser")]
1850    #[test]
1851    fn scrollbar_color_error_carries_the_trimmed_input() {
1852        assert_eq!(
1853            parse_style_scrollbar_color("  red  "),
1854            Err(StyleScrollbarColorParseError::InvalidValue("red"))
1855        );
1856    }
1857
1858    #[cfg(feature = "parser")]
1859    #[test]
1860    fn scrollbar_color_round_trips_through_its_printer() {
1861        let samples = [
1862            StyleScrollbarColor::Auto,
1863            StyleScrollbarColor::Custom(ScrollbarColorCustom {
1864                thumb: ColorU::RED,
1865                track: ColorU::BLUE,
1866            }),
1867            StyleScrollbarColor::Custom(ScrollbarColorCustom {
1868                thumb: ColorU::TRANSPARENT,
1869                track: ColorU::TRANSPARENT,
1870            }),
1871            StyleScrollbarColor::Custom(ScrollbarColorCustom {
1872                thumb: ColorU { r: 0, g: 0, b: 0, a: 100 },
1873                track: ColorU { r: 1, g: 2, b: 3, a: 4 },
1874            }),
1875            StyleScrollbarColor::Custom(ScrollbarColorCustom {
1876                thumb: ColorU::WHITE,
1877                track: ColorU::BLACK,
1878            }),
1879        ];
1880        for value in samples {
1881            let encoded = value.print_as_css_value();
1882            assert_eq!(
1883                parse_style_scrollbar_color(&encoded),
1884                Ok(value),
1885                "{encoded} did not decode back to {value:?}"
1886            );
1887        }
1888    }
1889
1890    // ======================================================================
1891    // parse_scrollbar_visibility_mode  (parser)
1892    // ======================================================================
1893
1894    #[cfg(feature = "parser")]
1895    #[test]
1896    fn visibility_mode_parses_its_three_keywords_and_trims() {
1897        assert_eq!(
1898            parse_scrollbar_visibility_mode("always"),
1899            Ok(ScrollbarVisibilityMode::Always)
1900        );
1901        assert_eq!(
1902            parse_scrollbar_visibility_mode(" when-scrolling\t"),
1903            Ok(ScrollbarVisibilityMode::WhenScrolling)
1904        );
1905        assert_eq!(
1906            parse_scrollbar_visibility_mode("auto"),
1907            Ok(ScrollbarVisibilityMode::Auto)
1908        );
1909    }
1910
1911    #[cfg(feature = "parser")]
1912    #[test]
1913    fn visibility_mode_rejects_near_misses_and_garbage() {
1914        for input in [
1915            "when scrolling", // space instead of hyphen
1916            "whenscrolling",
1917            "when-scrolling-",
1918            "-when-scrolling",
1919            "ALWAYS",
1920            "always;",
1921            "always auto",
1922        ] {
1923            assert!(
1924                parse_scrollbar_visibility_mode(input).is_err(),
1925                "expected {input:?} to be rejected"
1926            );
1927        }
1928        for input in GARBAGE {
1929            assert!(
1930                parse_scrollbar_visibility_mode(input).is_err(),
1931                "expected {input:?} to be rejected"
1932            );
1933        }
1934    }
1935
1936    #[cfg(feature = "parser")]
1937    #[test]
1938    fn visibility_mode_survives_huge_and_nested_input() {
1939        assert!(parse_scrollbar_visibility_mode(&"a".repeat(1_000_000)).is_err());
1940        assert!(parse_scrollbar_visibility_mode(&"always".repeat(200_000)).is_err());
1941        assert!(parse_scrollbar_visibility_mode(&"[".repeat(10_000)).is_err());
1942    }
1943
1944    #[cfg(feature = "parser")]
1945    #[test]
1946    fn visibility_mode_round_trips_through_its_printer() {
1947        for value in [
1948            ScrollbarVisibilityMode::Always,
1949            ScrollbarVisibilityMode::WhenScrolling,
1950            ScrollbarVisibilityMode::Auto,
1951        ] {
1952            let encoded = value.print_as_css_value();
1953            assert_eq!(
1954                parse_scrollbar_visibility_mode(&encoded),
1955                Ok(value),
1956                "{encoded} did not decode back to {value:?}"
1957            );
1958        }
1959    }
1960
1961    // ======================================================================
1962    // parse_time_ms  (private parser)
1963    // ======================================================================
1964
1965    #[cfg(feature = "parser")]
1966    #[test]
1967    fn parse_time_ms_accepts_bare_zero_and_both_units() {
1968        assert_eq!(parse_time_ms("0"), Some(0));
1969        assert_eq!(parse_time_ms("0ms"), Some(0));
1970        assert_eq!(parse_time_ms("0s"), Some(0));
1971        assert_eq!(parse_time_ms("500ms"), Some(500));
1972        assert_eq!(parse_time_ms("1s"), Some(1000));
1973        assert_eq!(parse_time_ms("1.5s"), Some(1500));
1974        assert_eq!(parse_time_ms("  200ms  "), Some(200));
1975        assert_eq!(parse_time_ms("200MS"), Some(200), "units are case-insensitive");
1976    }
1977
1978    /// The scrollbar fade fields are a bare millisecond `u32`, so a `t` (tick)
1979    /// value has to be CONVERTED at the nominal frame rate on the way in, not
1980    /// passed through. `60t` is one second; passing the raw tick count through
1981    /// would make it 60ms.
1982    #[cfg(feature = "parser")]
1983    #[test]
1984    fn parse_time_ms_converts_the_tick_unit_to_milliseconds() {
1985        assert_eq!(parse_time_ms("60t"), Some(1000));
1986        assert_eq!(parse_time_ms("30t"), Some(500));
1987        assert_eq!(parse_time_ms("1t"), Some(16));
1988        assert_eq!(parse_time_ms("0t"), Some(0));
1989        assert_ne!(parse_time_ms("60t"), Some(60), "ticks passed through as ms");
1990    }
1991
1992    /// A unit is mandatory (except for a bare `0`) and must be attached to the
1993    /// number — `"1 s"` has an interior space and cannot parse.
1994    #[cfg(feature = "parser")]
1995    #[test]
1996    fn parse_time_ms_requires_an_attached_unit() {
1997        assert_eq!(parse_time_ms("500"), None);
1998        assert_eq!(parse_time_ms("1 s"), None);
1999        assert_eq!(parse_time_ms("500 ms"), None);
2000        assert_eq!(parse_time_ms("ms"), None);
2001        assert_eq!(parse_time_ms("s"), None);
2002        assert_eq!(parse_time_ms("500px"), None);
2003        assert_eq!(parse_time_ms("500msms"), None);
2004    }
2005
2006    #[cfg(feature = "parser")]
2007    #[test]
2008    fn parse_time_ms_rejects_empty_blank_unicode_and_garbage() {
2009        for input in ["", " ", "   ", "\t\n", "\u{1F600}", "e\u{0301}", "٥ms", "500ms"] {
2010            assert_eq!(parse_time_ms(input), None, "expected {input:?} to be rejected");
2011        }
2012    }
2013
2014    #[cfg(feature = "parser")]
2015    #[test]
2016    fn parse_time_ms_rejects_negative_durations() {
2017        assert_eq!(parse_time_ms("-1ms"), None);
2018        assert_eq!(parse_time_ms("-0.5s"), None);
2019        assert_eq!(parse_time_ms("-inf ms"), None);
2020    }
2021
2022    /// Negative *zero* is not less than zero in IEEE-754, so it slips past the
2023    /// `< 0.0` guard and casts to 0 — harmless, but worth pinning.
2024    #[cfg(feature = "parser")]
2025    #[test]
2026    fn parse_time_ms_accepts_negative_zero_as_zero() {
2027        assert_eq!(parse_time_ms("-0ms"), Some(0));
2028        assert_eq!(parse_time_ms("-0.0s"), Some(0));
2029    }
2030
2031    /// The float -> u32 cast saturates instead of wrapping or panicking:
2032    /// `inf` clamps to `u32::MAX`, `NaN` becomes 0. Both are *safe* (no UB, no
2033    /// panic), but note that `"infms"` and `"NaNms"` are accepted as durations
2034    /// at all — a stricter parser would reject non-finite times outright.
2035    #[cfg(feature = "parser")]
2036    #[test]
2037    fn parse_time_ms_saturates_on_non_finite_and_huge_values() {
2038        assert_eq!(parse_time_ms("infms"), Some(u32::MAX));
2039        assert_eq!(parse_time_ms("infinityms"), Some(u32::MAX));
2040        assert_eq!(parse_time_ms("infs"), Some(u32::MAX));
2041        assert_eq!(parse_time_ms("nanms"), Some(0));
2042        assert_eq!(parse_time_ms("NaNms"), Some(0));
2043
2044        assert_eq!(parse_time_ms("1e30ms"), Some(u32::MAX));
2045        assert_eq!(parse_time_ms("1e400ms"), Some(u32::MAX), "overflows f32 to inf");
2046        assert_eq!(parse_time_ms("4294967296ms"), Some(u32::MAX), "2^32 clamps");
2047        assert_eq!(parse_time_ms("1e-30ms"), Some(0), "underflows to zero");
2048
2049        // A million digits must saturate, not hang.
2050        let long_number = format!("{}ms", "9".repeat(100_000));
2051        assert_eq!(parse_time_ms(&long_number), Some(u32::MAX));
2052    }
2053
2054    /// Seconds are multiplied by 1000 *before* the cast, so a value that fits in
2055    /// a u32 as seconds can still saturate as milliseconds.
2056    #[cfg(feature = "parser")]
2057    #[test]
2058    fn parse_time_ms_saturates_when_seconds_overflow_milliseconds() {
2059        // Exact while the millisecond product stays inside f32's integer range.
2060        assert_eq!(parse_time_ms("1000s"), Some(1_000_000));
2061        assert_eq!(parse_time_ms("16777s"), Some(16_777_000));
2062
2063        // Past u32::MAX milliseconds the cast clamps instead of wrapping.
2064        assert_eq!(parse_time_ms("4294968s"), Some(u32::MAX));
2065        assert_eq!(parse_time_ms("5000000s"), Some(u32::MAX));
2066
2067        // Just under the clamp, the f32 product is only accurate to ~256ms
2068        // (the ulp at that magnitude) — near, but no longer exact.
2069        let ms = parse_time_ms("4294967s").expect("4294967s must parse");
2070        assert!(
2071            ms.abs_diff(4_294_967_000) <= 512,
2072            "4294967s decoded to {ms}, which is nowhere near 4294967000ms"
2073        );
2074
2075        assert_eq!(parse_time_ms("0.0005s"), Some(0), "sub-ms truncates toward zero");
2076    }
2077
2078    // ======================================================================
2079    // parse_scrollbar_fade_delay / parse_scrollbar_fade_duration  (parsers)
2080    // ======================================================================
2081
2082    #[cfg(feature = "parser")]
2083    #[test]
2084    fn fade_parsers_accept_the_documented_syntax() {
2085        assert_eq!(
2086            parse_scrollbar_fade_delay("500ms"),
2087            Ok(ScrollbarFadeDelay::new(500))
2088        );
2089        assert_eq!(parse_scrollbar_fade_delay("0"), Ok(ScrollbarFadeDelay::ZERO));
2090        assert_eq!(
2091            parse_scrollbar_fade_delay(" 1s "),
2092            Ok(ScrollbarFadeDelay::new(1000))
2093        );
2094        assert_eq!(
2095            parse_scrollbar_fade_duration("200ms"),
2096            Ok(ScrollbarFadeDuration::new(200))
2097        );
2098        assert_eq!(
2099            parse_scrollbar_fade_duration("0"),
2100            Ok(ScrollbarFadeDuration::ZERO)
2101        );
2102    }
2103
2104    #[cfg(feature = "parser")]
2105    #[test]
2106    fn fade_parsers_reject_garbage_and_keep_the_raw_input_in_the_error() {
2107        for input in GARBAGE {
2108            assert!(
2109                parse_scrollbar_fade_delay(input).is_err(),
2110                "delay: expected {input:?} to be rejected"
2111            );
2112            assert!(
2113                parse_scrollbar_fade_duration(input).is_err(),
2114                "duration: expected {input:?} to be rejected"
2115            );
2116        }
2117
2118        let raw = "  bogus  ";
2119        assert_eq!(
2120            parse_scrollbar_fade_delay(raw),
2121            Err(ScrollbarFadeDelayParseError::InvalidValue(raw))
2122        );
2123        assert_eq!(
2124            parse_scrollbar_fade_duration(raw),
2125            Err(ScrollbarFadeDurationParseError::InvalidValue(raw))
2126        );
2127    }
2128
2129    #[cfg(feature = "parser")]
2130    #[test]
2131    fn fade_parsers_reject_negative_delays() {
2132        assert!(parse_scrollbar_fade_delay("-1ms").is_err());
2133        assert!(parse_scrollbar_fade_delay("-500ms").is_err());
2134        assert!(parse_scrollbar_fade_duration("-0.5s").is_err());
2135    }
2136
2137    #[cfg(feature = "parser")]
2138    #[test]
2139    fn fade_parsers_saturate_instead_of_overflowing() {
2140        assert_eq!(
2141            parse_scrollbar_fade_delay("1e30ms"),
2142            Ok(ScrollbarFadeDelay::new(u32::MAX))
2143        );
2144        assert_eq!(
2145            parse_scrollbar_fade_duration("99999999999999s"),
2146            Ok(ScrollbarFadeDuration::new(u32::MAX))
2147        );
2148    }
2149
2150    #[cfg(feature = "parser")]
2151    #[test]
2152    fn fade_parsers_survive_huge_and_nested_input() {
2153        assert!(parse_scrollbar_fade_delay(&"a".repeat(1_000_000)).is_err());
2154        assert!(parse_scrollbar_fade_duration(&"0ms".repeat(300_000)).is_err());
2155        assert!(parse_scrollbar_fade_delay(&"(".repeat(10_000)).is_err());
2156        assert!(parse_scrollbar_fade_duration(&"[".repeat(10_000)).is_err());
2157    }
2158
2159    /// encode -> decode is the identity for every millisecond count an `f32` can
2160    /// represent exactly (`<= 2^24`), including the `0` special case and the
2161    /// `u32::MAX` extreme (whose f32 rounding lands back on `u32::MAX` after the
2162    /// saturating cast).
2163    #[cfg(feature = "parser")]
2164    #[test]
2165    fn fade_delay_round_trips_exactly_up_to_two_pow_24() {
2166        for ms in [
2167            0u32,
2168            1,
2169            8,
2170            16,
2171            200,
2172            500,
2173            65_535,
2174            1_000_000,
2175            TWO_POW_24 - 1,
2176            TWO_POW_24,
2177            u32::MAX,
2178        ] {
2179            let value = ScrollbarFadeDelay::new(ms);
2180            let encoded = value.print_as_css_value();
2181            assert_eq!(
2182                parse_scrollbar_fade_delay(&encoded),
2183                Ok(value),
2184                "{ms}ms encoded as {encoded:?} did not decode back"
2185            );
2186
2187            let value = ScrollbarFadeDuration::new(ms);
2188            let encoded = value.print_as_css_value();
2189            assert_eq!(
2190                parse_scrollbar_fade_duration(&encoded),
2191                Ok(value),
2192                "{ms}ms encoded as {encoded:?} did not decode back"
2193            );
2194        }
2195    }
2196
2197    /// Above 2^24 the round-trip is lossy: the value is snapped to the nearest
2198    /// representable `f32`. Pinned as a precision limit of the shared duration
2199    /// parser (a delay is never realistically > 4.6 hours, so this is benign).
2200    #[cfg(feature = "parser")]
2201    #[test]
2202    fn fade_delay_round_trip_is_lossy_above_two_pow_24() {
2203        let value = ScrollbarFadeDelay::new(TWO_POW_24 + 1);
2204        let decoded = parse_scrollbar_fade_delay(&value.print_as_css_value()).unwrap();
2205        assert_ne!(decoded, value, "expected precision loss above 2^24");
2206        assert_eq!(decoded.ms, TWO_POW_24, "must snap down to the nearest f32");
2207    }
2208
2209    // ======================================================================
2210    // Error to_contained / to_shared  (getters)
2211    // ======================================================================
2212
2213    /// Strings that stress the owned<->borrowed error conversions: empty, blank,
2214    /// multibyte, embedded NUL, and a 100k-byte payload.
2215    fn error_payloads() -> [String; 6] {
2216        [
2217            String::new(),
2218            String::from(" "),
2219            String::from("thick"),
2220            String::from("\u{1F600}\u{0301}"),
2221            String::from("nul\0inside"),
2222            "x".repeat(100_000),
2223        ]
2224    }
2225
2226    #[test]
2227    fn layout_scrollbar_width_error_round_trips_through_owned_and_back() {
2228        for payload in error_payloads() {
2229            let shared = LayoutScrollbarWidthParseError::InvalidValue(&payload);
2230            let owned = shared.to_contained();
2231            assert_eq!(
2232                owned,
2233                LayoutScrollbarWidthParseErrorOwned::InvalidValue(payload.clone().into())
2234            );
2235            assert_eq!(owned.to_shared(), shared, "owned -> shared lost information");
2236            assert_eq!(
2237                owned.to_shared().to_contained(),
2238                owned,
2239                "conversion is not idempotent"
2240            );
2241        }
2242    }
2243
2244    #[test]
2245    fn visibility_mode_error_round_trips_through_owned_and_back() {
2246        for payload in error_payloads() {
2247            let shared = ScrollbarVisibilityModeParseError::InvalidValue(&payload);
2248            let owned = shared.to_contained();
2249            assert_eq!(owned.to_shared(), shared);
2250            assert_eq!(owned.to_shared().to_contained(), owned);
2251        }
2252    }
2253
2254    #[test]
2255    fn fade_delay_and_duration_errors_round_trip_through_owned_and_back() {
2256        for payload in error_payloads() {
2257            let delay = ScrollbarFadeDelayParseError::InvalidValue(&payload);
2258            let owned_delay = delay.to_contained();
2259            assert_eq!(owned_delay.to_shared(), delay);
2260            assert_eq!(owned_delay.to_shared().to_contained(), owned_delay);
2261
2262            let duration = ScrollbarFadeDurationParseError::InvalidValue(&payload);
2263            let owned_duration = duration.to_contained();
2264            assert_eq!(owned_duration.to_shared(), duration);
2265            assert_eq!(owned_duration.to_shared().to_contained(), owned_duration);
2266        }
2267    }
2268
2269    #[test]
2270    fn scrollbar_color_invalid_value_error_round_trips_through_owned_and_back() {
2271        for payload in error_payloads() {
2272            let shared = StyleScrollbarColorParseError::InvalidValue(&payload);
2273            let owned = shared.to_contained();
2274            assert_eq!(
2275                owned,
2276                StyleScrollbarColorParseErrorOwned::InvalidValue(payload.clone().into())
2277            );
2278            assert_eq!(owned.to_shared(), shared);
2279            assert_eq!(owned.to_shared().to_contained(), owned);
2280        }
2281    }
2282
2283    /// The nested `Color` variant must delegate to the color error's own
2284    /// conversion rather than flattening to a string.
2285    #[cfg(feature = "parser")]
2286    #[test]
2287    fn scrollbar_color_nested_color_error_round_trips_through_owned_and_back() {
2288        let shared = parse_style_scrollbar_color("notacolor blue").unwrap_err();
2289        assert!(matches!(shared, StyleScrollbarColorParseError::Color(_)));
2290
2291        let owned = shared.to_contained();
2292        assert!(matches!(owned, StyleScrollbarColorParseErrorOwned::Color(_)));
2293        assert_eq!(owned.to_shared(), shared, "nested color error lost information");
2294        assert_eq!(owned.to_shared().to_contained(), owned);
2295    }
2296
2297    /// Error `Display` must always name the offending input, otherwise a CSS
2298    /// diagnostic is useless. (`Debug` is implemented as `Display` here.)
2299    #[cfg(feature = "parser")]
2300    #[test]
2301    fn error_display_mentions_the_offending_input() {
2302        let width = parse_layout_scrollbar_width("thick").unwrap_err();
2303        assert!(format!("{width}").contains("thick"), "{width}");
2304        assert!(format!("{width:?}").contains("thick"), "{width:?}");
2305
2306        let color = parse_style_scrollbar_color("red").unwrap_err();
2307        assert!(format!("{color}").contains("red"), "{color}");
2308
2309        let vis = parse_scrollbar_visibility_mode("sometimes").unwrap_err();
2310        assert!(format!("{vis}").contains("sometimes"), "{vis}");
2311
2312        let delay = parse_scrollbar_fade_delay("soon").unwrap_err();
2313        assert!(format!("{delay}").contains("soon"), "{delay}");
2314
2315        let duration = parse_scrollbar_fade_duration("briefly").unwrap_err();
2316        assert!(format!("{duration}").contains("briefly"), "{duration}");
2317    }
2318
2319    /// Displaying an error whose payload is empty or exotic must not panic on a
2320    /// byte/char boundary.
2321    #[test]
2322    fn error_display_does_not_panic_on_exotic_payloads() {
2323        for payload in error_payloads() {
2324            let err = LayoutScrollbarWidthParseError::InvalidValue(&payload);
2325            assert!(!format!("{err}").is_empty());
2326            let owned = err.to_contained();
2327            assert!(!format!("{}", owned.to_shared()).is_empty());
2328        }
2329    }
2330}