Skip to main content

azul_css/props/style/
scrollbar.rs

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