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