1
//! CSS properties for styling scrollbars.
2

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

            
6
use crate::props::{
7
    basic::{
8
        color::{parse_css_color, ColorU, CssColorParseError, CssColorParseErrorOwned},
9
    },
10
    formatter::PrintAsCssValue,
11
    layout::{
12
        dimensions::LayoutWidth,
13
        spacing::{LayoutPaddingLeft, LayoutPaddingRight},
14
    },
15
    style::background::StyleBackgroundContent,
16
};
17

            
18
// ============================================================================
19
// CSS Standard Scroll Behavior Properties
20
// ============================================================================
21

            
22
/// CSS `scroll-behavior` property - controls smooth scrolling
23
/// <https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-behavior>
24
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
25
#[repr(C)]
26
pub enum ScrollBehavior {
27
    /// Scrolling jumps instantly to the final position
28
    #[default]
29
    Auto,
30
    /// Scrolling animates smoothly to the final position
31
    Smooth,
32
}
33

            
34
impl PrintAsCssValue for ScrollBehavior {
35
2
    fn print_as_css_value(&self) -> String {
36
2
        match self {
37
1
            Self::Auto => "auto".to_string(),
38
1
            Self::Smooth => "smooth".to_string(),
39
        }
40
2
    }
41
}
42

            
43
/// CSS `overscroll-behavior` property - controls overscroll effects
44
/// <https://developer.mozilla.org/en-US/docs/Web/CSS/overscroll-behavior>
45
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
46
#[repr(C)]
47
pub enum OverscrollBehavior {
48
    /// Default scroll overflow behavior (bounce/glow effects, scroll chaining)
49
    #[default]
50
    Auto,
51
    /// Prevents scroll chaining to parent elements, but allows local overscroll effects
52
    Contain,
53
    /// No scroll chaining and no overscroll effects (hard stop at boundaries)
54
    None,
55
}
56

            
57
impl PrintAsCssValue for OverscrollBehavior {
58
3
    fn print_as_css_value(&self) -> String {
59
3
        match self {
60
1
            Self::Auto => "auto".to_string(),
61
1
            Self::Contain => "contain".to_string(),
62
1
            Self::None => "none".to_string(),
63
        }
64
3
    }
65
}
66

            
67
// ============================================================================
68
// Extended Scroll Configuration (Azul-specific)
69
// ============================================================================
70

            
71
/// Scroll physics configuration for momentum scrolling
72
///
73
/// This controls how scrolling feels - the "weight" and "friction" of the scroll.
74
/// Different platforms have different scroll physics (iOS vs Android vs Windows).
75
#[derive(Debug, Copy, Clone, PartialEq, PartialOrd)]
76
#[repr(C)]
77
pub struct ScrollPhysics {
78
    /// Smooth scroll animation duration in milliseconds (default: 300ms)
79
    /// Only used when scroll-behavior: smooth
80
    pub smooth_scroll_duration_ms: u32,
81

            
82
    /// Deceleration rate for momentum scrolling (0.0 = instant stop, 1.0 = never stops)
83
    /// Typical values: 0.95 (fast deceleration) to 0.998 (slow, iOS-like)
84
    /// Default: 0.95
85
    pub deceleration_rate: f32,
86

            
87
    /// Minimum velocity threshold to start momentum scrolling (pixels/second)
88
    /// Below this, scrolling stops immediately. Default: 50.0
89
    pub min_velocity_threshold: f32,
90

            
91
    /// Maximum scroll velocity (pixels/second). Default: 8000.0
92
    pub max_velocity: f32,
93

            
94
    /// Scroll wheel multiplier. Default: 1.0
95
    /// Values > 1.0 make scrolling faster, < 1.0 slower
96
    pub wheel_multiplier: f32,
97

            
98
    /// Whether to invert scroll direction (natural scrolling). Default: false
99
    pub invert_direction: bool,
100

            
101
    /// Overscroll elasticity (0.0 = no bounce, 1.0 = full bounce like iOS)
102
    /// Only applies when overscroll-behavior: auto. Default: 0.0 (no bounce)
103
    pub overscroll_elasticity: f32,
104

            
105
    /// Maximum overscroll distance in pixels before rubber-banding stops
106
    /// Default: 100.0
107
    pub max_overscroll_distance: f32,
108

            
109
    /// Bounce-back duration when releasing overscroll (milliseconds)
110
    /// Default: 400
111
    pub bounce_back_duration_ms: u32,
112

            
113
    /// Timer tick interval in milliseconds for the scroll physics timer.
114
    /// Should match the monitor refresh rate (e.g. 16ms for 60Hz, 8ms for 120Hz).
115
    /// Default: 16 (60 Hz)
116
    pub timer_interval_ms: u32,
117

            
118
    /// Spring duration for target-seeking animated scrolls
119
    /// (`scroll_to_animated`) that originate from a MOUSE WHEEL. Wheel
120
    /// steps animated with the trackpad's long bounce constant feel
121
    /// jarring - a discrete click wants a short, snappy glide. Other
122
    /// devices use `bounce_back_duration_ms`. Default: 120.
123
    pub wheel_animate_bounce_ms: u32,
124
}
125

            
126
impl Default for ScrollPhysics {
127
37388
    fn default() -> Self {
128
37388
        Self {
129
37388
            smooth_scroll_duration_ms: 300,
130
37388
            deceleration_rate: 0.95,
131
37388
            min_velocity_threshold: 50.0,
132
37388
            max_velocity: 8000.0,
133
37388
            wheel_multiplier: 1.0,
134
37388
            invert_direction: false,
135
37388
            overscroll_elasticity: 0.0, // No bounce by default (Windows-like)
136
37388
            max_overscroll_distance: 100.0,
137
37388
            bounce_back_duration_ms: 400,
138
37388
            timer_interval_ms: 16,
139
37388
            wheel_animate_bounce_ms: 120,
140
37388
        }
141
37388
    }
142
}
143

            
144
impl ScrollPhysics {
145
    /// iOS-like scroll physics with momentum and bounce
146
62
    #[must_use] pub const fn ios() -> Self {
147
62
        Self {
148
62
            smooth_scroll_duration_ms: 300,
149
62
            deceleration_rate: 0.998,
150
62
            min_velocity_threshold: 20.0,
151
62
            max_velocity: 8000.0,
152
62
            wheel_multiplier: 1.0,
153
62
            invert_direction: true, // Natural scrolling
154
62
            overscroll_elasticity: 0.5,
155
62
            max_overscroll_distance: 120.0,
156
62
            bounce_back_duration_ms: 500,
157
62
            timer_interval_ms: 16,
158
62
            wheel_animate_bounce_ms: 120,
159
62
        }
160
62
    }
161

            
162
    /// macOS-like scroll physics
163
65
    #[must_use] pub const fn macos() -> Self {
164
65
        Self {
165
65
            smooth_scroll_duration_ms: 250,
166
65
            deceleration_rate: 0.997,
167
65
            min_velocity_threshold: 30.0,
168
65
            max_velocity: 6000.0,
169
65
            wheel_multiplier: 1.0,
170
65
            invert_direction: true, // Natural scrolling by default
171
65
            overscroll_elasticity: 0.3,
172
65
            max_overscroll_distance: 80.0,
173
65
            bounce_back_duration_ms: 400,
174
65
            timer_interval_ms: 16,
175
65
            wheel_animate_bounce_ms: 120,
176
65
        }
177
65
    }
178

            
179
    /// Windows-like scroll physics (no momentum, no bounce)
180
87
    #[must_use] pub const fn windows() -> Self {
181
87
        Self {
182
87
            smooth_scroll_duration_ms: 200,
183
87
            deceleration_rate: 0.9,
184
87
            min_velocity_threshold: 100.0,
185
87
            max_velocity: 4000.0,
186
87
            wheel_multiplier: 1.0,
187
87
            invert_direction: false,
188
87
            overscroll_elasticity: 0.0,
189
87
            max_overscroll_distance: 0.0,
190
87
            bounce_back_duration_ms: 200,
191
87
            timer_interval_ms: 16,
192
87
            wheel_animate_bounce_ms: 120,
193
87
        }
194
87
    }
195

            
196
    /// Android-like scroll physics
197
29
    #[must_use] pub const fn android() -> Self {
198
29
        Self {
199
29
            smooth_scroll_duration_ms: 250,
200
29
            deceleration_rate: 0.996,
201
29
            min_velocity_threshold: 40.0,
202
29
            max_velocity: 8000.0,
203
29
            wheel_multiplier: 1.0,
204
29
            invert_direction: false,
205
29
            overscroll_elasticity: 0.2, // Subtle glow effect
206
29
            max_overscroll_distance: 60.0,
207
29
            bounce_back_duration_ms: 300,
208
29
            timer_interval_ms: 16,
209
29
            wheel_animate_bounce_ms: 120,
210
29
        }
211
29
    }
212
}
213

            
214
impl_option!(
215
    ScrollPhysics,
216
    OptionScrollPhysics,
217
    [Debug, Copy, Clone, PartialEq, PartialOrd]
218
);
219

            
220
// ============================================================================
221
// Scrollbar Visibility Mode (CSS: -azul-scrollbar-visibility)
222
// ============================================================================
223

            
224
/// Controls when the scrollbar is displayed.
225
///
226
/// This is a per-element CSS property (`-azul-scrollbar-visibility`) that
227
/// determines the scrollbar presentation style. It interacts with the
228
/// OS-level `ScrollbarPreferences.visibility` (from System Preferences)
229
/// when set to `Auto`.
230
///
231
/// - `Always`: Classic, always-visible scrollbar (Chrome/Windows/Linux default).
232
///   Scrollbar reserves layout space.
233
/// - `WhenScrolling`: Overlay scrollbar that fades in on scroll activity
234
///   and fades out after a delay. Does not reserve layout space.
235
/// - `Auto`: Use the OS preference. On macOS this typically means `WhenScrolling`,
236
///   on Windows/Linux this typically means `Always`.
237
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
238
#[repr(C)]
239
pub enum ScrollbarVisibilityMode {
240
    /// Scrollbar is always visible (Chrome/Windows/Linux default).
241
    /// Reserves layout space.
242
    #[default]
243
    Always,
244
    /// Scrollbar appears on scroll and fades out after inactivity.
245
    /// Does not reserve layout space (overlay).
246
    WhenScrolling,
247
    /// Use the OS-level scrollbar preference.
248
    Auto,
249
}
250

            
251
impl PrintAsCssValue for ScrollbarVisibilityMode {
252
6
    fn print_as_css_value(&self) -> String {
253
6
        match self {
254
2
            Self::Always => "always".to_string(),
255
2
            Self::WhenScrolling => "when-scrolling".to_string(),
256
2
            Self::Auto => "auto".to_string(),
257
        }
258
6
    }
259
}
260

            
261
// ============================================================================
262
// Scrollbar Fade Delay (CSS: -azul-scrollbar-fade-delay)
263
// ============================================================================
264

            
265
/// Time in milliseconds before the overlay scrollbar starts fading out.
266
///
267
/// A value of 0 means the scrollbar never fades (always visible).
268
/// Typical values: 500ms (macOS), 0ms (Windows).
269
///
270
/// CSS syntax: `-azul-scrollbar-fade-delay: 500ms;` or `-azul-scrollbar-fade-delay: 0;`
271
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
272
#[repr(C)]
273
pub struct ScrollbarFadeDelay {
274
    /// Delay in milliseconds
275
    pub ms: u32,
276
}
277

            
278
impl ScrollbarFadeDelay {
279
366
    #[must_use] pub const fn new(ms: u32) -> Self { Self { ms } }
280
    pub const ZERO: Self = Self { ms: 0 };
281
}
282

            
283
impl PrintAsCssValue for ScrollbarFadeDelay {
284
16
    fn print_as_css_value(&self) -> String {
285
16
        if self.ms == 0 { "0".to_string() } else { format!("{}ms", self.ms) }
286
16
    }
287
}
288

            
289
// ============================================================================
290
// Scrollbar Fade Duration (CSS: -azul-scrollbar-fade-duration)
291
// ============================================================================
292

            
293
/// Duration in milliseconds of the scrollbar fade-out animation.
294
///
295
/// A value of 0 means instant disappearance (no animation).
296
/// Typical values: 200ms (macOS), 0ms (Windows).
297
///
298
/// CSS syntax: `-azul-scrollbar-fade-duration: 200ms;` or `-azul-scrollbar-fade-duration: 0;`
299
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
300
#[repr(C)]
301
pub struct ScrollbarFadeDuration {
302
    /// Duration in milliseconds
303
    pub ms: u32,
304
}
305

            
306
impl ScrollbarFadeDuration {
307
293
    #[must_use] pub const fn new(ms: u32) -> Self { Self { ms } }
308
    pub const ZERO: Self = Self { ms: 0 };
309
}
310

            
311
impl PrintAsCssValue for ScrollbarFadeDuration {
312
14
    fn print_as_css_value(&self) -> String {
313
14
        if self.ms == 0 { "0".to_string() } else { format!("{}ms", self.ms) }
314
14
    }
315
}
316

            
317
// ============================================================================
318
// Per-node Overflow Scrolling Mode (CSS: -azul-overflow-scrolling)
319
// ============================================================================
320

            
321
/// Controls per-node rubber-banding / momentum scrolling behavior.
322
///
323
/// Analogous to `-webkit-overflow-scrolling` on iOS Safari.
324
///
325
/// - `Auto`: Use the global `ScrollPhysics` from `SystemStyle`. On platforms
326
///   with `overscroll_elasticity == 0.0` (e.g. Windows), this means no rubber-banding.
327
/// - `Touch`: Force momentum scrolling with rubber-banding on this node,
328
///   regardless of the global `ScrollPhysics` setting. Uses iOS-like elasticity
329
///   if the global elasticity is zero.
330
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
331
#[repr(C)]
332
pub enum OverflowScrolling {
333
    /// Use the global scroll physics (platform default). No rubber-banding on Windows.
334
    #[default]
335
    Auto,
336
    /// Force rubber-banding / momentum scrolling on this node (like iOS/macOS).
337
    Touch,
338
}
339

            
340
impl PrintAsCssValue for OverflowScrolling {
341
2
    fn print_as_css_value(&self) -> String {
342
2
        match self {
343
1
            Self::Auto => "auto".to_string(),
344
1
            Self::Touch => "touch".to_string(),
345
        }
346
2
    }
347
}
348

            
349
// ============================================================================
350
// Standard Properties
351
// ============================================================================
352

            
353
/// Represents the standard `scrollbar-width` property.
354
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
355
#[repr(C)]
356
#[derive(Default)]
357
pub enum LayoutScrollbarWidth {
358
    #[default]
359
    Auto,
360
    Thin,
361
    None,
362
}
363

            
364

            
365
impl PrintAsCssValue for LayoutScrollbarWidth {
366
6
    fn print_as_css_value(&self) -> String {
367
6
        match self {
368
2
            Self::Auto => "auto".to_string(),
369
2
            Self::Thin => "thin".to_string(),
370
2
            Self::None => "none".to_string(),
371
        }
372
6
    }
373
}
374

            
375
/// Wrapper struct for custom scrollbar colors (thumb and track)
376
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
377
#[repr(C)]
378
pub struct ScrollbarColorCustom {
379
    pub thumb: ColorU,
380
    pub track: ColorU,
381
}
382

            
383
/// Represents the standard `scrollbar-color` property.
384
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
385
#[repr(C, u8)]
386
#[derive(Default)]
387
pub enum StyleScrollbarColor {
388
    #[default]
389
    Auto,
390
    Custom(ScrollbarColorCustom),
391
}
392

            
393

            
394
impl PrintAsCssValue for StyleScrollbarColor {
395
7
    fn print_as_css_value(&self) -> String {
396
7
        match self {
397
2
            Self::Auto => "auto".to_string(),
398
5
            Self::Custom(c) => format!("{} {}", c.thumb.to_hash(), c.track.to_hash()),
399
        }
400
7
    }
401
}
402

            
403
// -- -webkit-prefixed Properties --
404

            
405
/// Holds info necessary for layouting / styling -webkit-scrollbar properties.
406
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
407
#[repr(C)]
408
pub struct ScrollbarInfo {
409
    /// Total width (or height for vertical scrollbars) of the scrollbar in pixels
410
    pub width: LayoutWidth,
411
    /// Padding of the scrollbar tracker, in pixels. The inner bar is `width - padding` pixels
412
    /// wide.
413
    pub padding_left: LayoutPaddingLeft,
414
    /// Padding of the scrollbar (right)
415
    pub padding_right: LayoutPaddingRight,
416
    /// Style of the scrollbar background
417
    /// (`-webkit-scrollbar` / `-webkit-scrollbar-track` / `-webkit-scrollbar-track-piece`
418
    /// combined)
419
    pub track: StyleBackgroundContent,
420
    /// Style of the scrollbar thumbs (the "up" / "down" arrows), (`-webkit-scrollbar-thumb`)
421
    pub thumb: StyleBackgroundContent,
422
    /// Styles the directional buttons on the scrollbar (`-webkit-scrollbar-button`)
423
    pub button: StyleBackgroundContent,
424
    /// If two scrollbars are present, addresses the (usually) bottom corner
425
    /// of the scrollable element, where two scrollbars might meet (`-webkit-scrollbar-corner`)
426
    pub corner: StyleBackgroundContent,
427
    /// Addresses the draggable resizing handle that appears above the
428
    /// `corner` at the bottom corner of some elements (`-webkit-resizer`)
429
    pub resizer: StyleBackgroundContent,
430
    /// Whether to clip the scrollbar to the container's border-radius.
431
    /// When true, if the container has rounded corners, the scrollbar will be
432
    /// clipped to those rounded corners instead of having rectangular edges.
433
    /// Default is false for classic scrollbars, true for overlay scrollbars.
434
    pub clip_to_container_border: bool,
435
    /// Scroll behavior for this scrollbar's container (auto or smooth)
436
    pub scroll_behavior: ScrollBehavior,
437
    /// Overscroll behavior for the X axis
438
    pub overscroll_behavior_x: OverscrollBehavior,
439
    /// Overscroll behavior for the Y axis  
440
    pub overscroll_behavior_y: OverscrollBehavior,
441
    /// Per-node overflow scrolling mode (`-azul-overflow-scrolling: auto | touch`)
442
    /// `Touch` forces rubber-banding on this node even when the global physics has no bounce.
443
    pub overflow_scrolling: OverflowScrolling,
444
}
445

            
446
impl Default for ScrollbarInfo {
447
14
    fn default() -> Self {
448
14
        SCROLLBAR_CLASSIC_LIGHT
449
14
    }
450
}
451

            
452
impl PrintAsCssValue for ScrollbarInfo {
453
3
    fn print_as_css_value(&self) -> String {
454
        // This is a custom format, not standard CSS
455
3
        format!(
456
3
            "width: {}; padding-left: {}; padding-right: {}; track: {}; thumb: {}; button: {}; \
457
3
             corner: {}; resizer: {}",
458
3
            self.width.print_as_css_value(),
459
3
            self.padding_left.print_as_css_value(),
460
3
            self.padding_right.print_as_css_value(),
461
3
            self.track.print_as_css_value(),
462
3
            self.thumb.print_as_css_value(),
463
3
            self.button.print_as_css_value(),
464
3
            self.corner.print_as_css_value(),
465
3
            self.resizer.print_as_css_value(),
466
        )
467
3
    }
468
}
469

            
470
/// Scrollbar style for both horizontal and vertical scrollbars.
471
#[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
472
#[repr(C)]
473
pub struct ScrollbarStyle {
474
    /// Horizontal scrollbar style, if any
475
    pub horizontal: ScrollbarInfo,
476
    /// Vertical scrollbar style, if any
477
    pub vertical: ScrollbarInfo,
478
}
479

            
480
impl PrintAsCssValue for ScrollbarStyle {
481
1
    fn print_as_css_value(&self) -> String {
482
        // This is a custom format, not standard CSS
483
1
        format!(
484
1
            "horz({}), vert({})",
485
1
            self.horizontal.print_as_css_value(),
486
1
            self.vertical.print_as_css_value()
487
        )
488
1
    }
489
}
490

            
491
// Formatting to Rust code
492
impl crate::codegen::format::FormatAsRustCode for ScrollbarStyle {
493
3
    fn format_as_rust_code(&self, tabs: usize) -> String {
494
3
        let t = String::from("    ").repeat(tabs);
495
3
        let t1 = String::from("    ").repeat(tabs + 1);
496
3
        format!(
497
3
            "ScrollbarStyle {{\r\n{}horizontal: {},\r\n{}vertical: {},\r\n{}}}",
498
            t1,
499
3
            crate::codegen::format::format_scrollbar_info(&self.horizontal, tabs + 1),
500
            t1,
501
3
            crate::codegen::format::format_scrollbar_info(&self.vertical, tabs + 1),
502
            t,
503
        )
504
3
    }
505
}
506

            
507
impl crate::codegen::format::FormatAsRustCode for LayoutScrollbarWidth {
508
2
    fn format_as_rust_code(&self, _tabs: usize) -> String {
509
2
        match self {
510
            Self::Auto => String::from("LayoutScrollbarWidth::Auto"),
511
1
            Self::Thin => String::from("LayoutScrollbarWidth::Thin"),
512
1
            Self::None => String::from("LayoutScrollbarWidth::None"),
513
        }
514
2
    }
515
}
516

            
517
impl crate::codegen::format::FormatAsRustCode for StyleScrollbarColor {
518
2
    fn format_as_rust_code(&self, _tabs: usize) -> String {
519
2
        match self {
520
1
            Self::Auto => String::from("StyleScrollbarColor::Auto"),
521
1
            Self::Custom(c) => format!(
522
1
                "StyleScrollbarColor::Custom(ScrollbarColorCustom {{ thumb: {}, track: {} }})",
523
1
                crate::codegen::format::format_color_value(&c.thumb),
524
1
                crate::codegen::format::format_color_value(&c.track)
525
            ),
526
        }
527
2
    }
528
}
529

            
530
impl crate::codegen::format::FormatAsRustCode for ScrollbarVisibilityMode {
531
1
    fn format_as_rust_code(&self, _tabs: usize) -> String {
532
1
        match self {
533
            Self::Always => String::from("ScrollbarVisibilityMode::Always"),
534
1
            Self::WhenScrolling => String::from("ScrollbarVisibilityMode::WhenScrolling"),
535
            Self::Auto => String::from("ScrollbarVisibilityMode::Auto"),
536
        }
537
1
    }
538
}
539

            
540
impl crate::codegen::format::FormatAsRustCode for ScrollbarFadeDelay {
541
2
    fn format_as_rust_code(&self, _tabs: usize) -> String {
542
2
        format!("ScrollbarFadeDelay::new({})", self.ms)
543
2
    }
544
}
545

            
546
impl crate::codegen::format::FormatAsRustCode for ScrollbarFadeDuration {
547
1
    fn format_as_rust_code(&self, _tabs: usize) -> String {
548
1
        format!("ScrollbarFadeDuration::new({})", self.ms)
549
1
    }
550
}
551

            
552
// --- Final Computed Style ---
553

            
554
/// The final, resolved style for a scrollbar, after considering both
555
/// standard and -webkit- properties. This struct is intended for use by the layout engine.
556
#[derive(Debug, Clone, PartialEq, Eq)]
557
pub struct ComputedScrollbarStyle {
558
    /// The width of the scrollbar. `None` signifies `scrollbar-width: none`.
559
    pub width: Option<LayoutWidth>,
560
    /// The color of the scrollbar thumb. `None` means use UA default.
561
    pub thumb_color: Option<ColorU>,
562
    /// The color of the scrollbar track. `None` means use UA default.
563
    pub track_color: Option<ColorU>,
564
}
565

            
566
impl Default for ComputedScrollbarStyle {
567
1
    fn default() -> Self {
568
1
        let default_info = ScrollbarInfo::default();
569
        Self {
570
1
            width: Some(default_info.width), // Default width from UA/platform
571
1
            thumb_color: match default_info.thumb {
572
1
                StyleBackgroundContent::Color(c) => Some(c),
573
                _ => None,
574
            },
575
1
            track_color: match default_info.track {
576
1
                StyleBackgroundContent::Color(c) => Some(c),
577
                _ => None,
578
            },
579
        }
580
1
    }
581
}
582

            
583
// --- Default Style Constants ---
584

            
585
/// A classic light-themed scrollbar, similar to older Windows versions.
586
pub const SCROLLBAR_CLASSIC_LIGHT: ScrollbarInfo = ScrollbarInfo {
587
    width: LayoutWidth::Px(crate::props::basic::pixel::PixelValue::const_px(17)),
588
    padding_left: LayoutPaddingLeft {
589
        inner: crate::props::basic::pixel::PixelValue::const_px(2),
590
    },
591
    padding_right: LayoutPaddingRight {
592
        inner: crate::props::basic::pixel::PixelValue::const_px(2),
593
    },
594
    track: StyleBackgroundContent::Color(ColorU {
595
        r: 241,
596
        g: 241,
597
        b: 241,
598
        a: 255,
599
    }),
600
    thumb: StyleBackgroundContent::Color(ColorU {
601
        r: 193,
602
        g: 193,
603
        b: 193,
604
        a: 255,
605
    }),
606
    button: StyleBackgroundContent::Color(ColorU {
607
        r: 163,
608
        g: 163,
609
        b: 163,
610
        a: 255,
611
    }),
612
    corner: StyleBackgroundContent::Color(ColorU {
613
        r: 241,
614
        g: 241,
615
        b: 241,
616
        a: 255,
617
    }),
618
    resizer: StyleBackgroundContent::Color(ColorU {
619
        r: 241,
620
        g: 241,
621
        b: 241,
622
        a: 255,
623
    }),
624
    clip_to_container_border: false,
625
    scroll_behavior: ScrollBehavior::Auto,
626
    overscroll_behavior_x: OverscrollBehavior::Auto,
627
    overscroll_behavior_y: OverscrollBehavior::Auto,
628
    overflow_scrolling: OverflowScrolling::Auto,
629
};
630

            
631
/// A classic dark-themed scrollbar.
632
pub const SCROLLBAR_CLASSIC_DARK: ScrollbarInfo = ScrollbarInfo {
633
    width: LayoutWidth::Px(crate::props::basic::pixel::PixelValue::const_px(17)),
634
    padding_left: LayoutPaddingLeft {
635
        inner: crate::props::basic::pixel::PixelValue::const_px(2),
636
    },
637
    padding_right: LayoutPaddingRight {
638
        inner: crate::props::basic::pixel::PixelValue::const_px(2),
639
    },
640
    track: StyleBackgroundContent::Color(ColorU {
641
        r: 45,
642
        g: 45,
643
        b: 45,
644
        a: 255,
645
    }),
646
    thumb: StyleBackgroundContent::Color(ColorU {
647
        r: 100,
648
        g: 100,
649
        b: 100,
650
        a: 255,
651
    }),
652
    button: StyleBackgroundContent::Color(ColorU {
653
        r: 120,
654
        g: 120,
655
        b: 120,
656
        a: 255,
657
    }),
658
    corner: StyleBackgroundContent::Color(ColorU {
659
        r: 45,
660
        g: 45,
661
        b: 45,
662
        a: 255,
663
    }),
664
    resizer: StyleBackgroundContent::Color(ColorU {
665
        r: 45,
666
        g: 45,
667
        b: 45,
668
        a: 255,
669
    }),
670
    clip_to_container_border: false,
671
    scroll_behavior: ScrollBehavior::Auto,
672
    overscroll_behavior_x: OverscrollBehavior::Auto,
673
    overscroll_behavior_y: OverscrollBehavior::Auto,
674
    overflow_scrolling: OverflowScrolling::Auto,
675
};
676

            
677
/// A modern, thin, overlay scrollbar inspired by macOS (Light Theme).
678
pub const SCROLLBAR_MACOS_LIGHT: ScrollbarInfo = ScrollbarInfo {
679
    width: LayoutWidth::Px(crate::props::basic::pixel::PixelValue::const_px(8)),
680
    padding_left: LayoutPaddingLeft {
681
        inner: crate::props::basic::pixel::PixelValue::const_px(0),
682
    },
683
    padding_right: LayoutPaddingRight {
684
        inner: crate::props::basic::pixel::PixelValue::const_px(0),
685
    },
686
    track: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
687
    thumb: StyleBackgroundContent::Color(ColorU {
688
        r: 0,
689
        g: 0,
690
        b: 0,
691
        a: 100,
692
    }), // semi-transparent black
693
    button: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
694
    corner: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
695
    resizer: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
696
    clip_to_container_border: true, // Overlay scrollbars should clip to rounded borders
697
    scroll_behavior: ScrollBehavior::Smooth,
698
    overscroll_behavior_x: OverscrollBehavior::Auto,
699
    overscroll_behavior_y: OverscrollBehavior::Auto,
700
    overflow_scrolling: OverflowScrolling::Auto,
701
};
702

            
703
/// A modern, thin, overlay scrollbar inspired by macOS (Dark Theme).
704
pub const SCROLLBAR_MACOS_DARK: ScrollbarInfo = ScrollbarInfo {
705
    width: LayoutWidth::Px(crate::props::basic::pixel::PixelValue::const_px(8)),
706
    padding_left: LayoutPaddingLeft {
707
        inner: crate::props::basic::pixel::PixelValue::const_px(0),
708
    },
709
    padding_right: LayoutPaddingRight {
710
        inner: crate::props::basic::pixel::PixelValue::const_px(0),
711
    },
712
    track: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
713
    thumb: StyleBackgroundContent::Color(ColorU {
714
        r: 255,
715
        g: 255,
716
        b: 255,
717
        a: 100,
718
    }), // semi-transparent white
719
    button: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
720
    corner: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
721
    resizer: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
722
    clip_to_container_border: true, // Overlay scrollbars should clip to rounded borders
723
    scroll_behavior: ScrollBehavior::Smooth,
724
    overscroll_behavior_x: OverscrollBehavior::Auto,
725
    overscroll_behavior_y: OverscrollBehavior::Auto,
726
    overflow_scrolling: OverflowScrolling::Auto,
727
};
728

            
729
/// A modern scrollbar inspired by Windows 11 (Light Theme).
730
pub const SCROLLBAR_WINDOWS_LIGHT: ScrollbarInfo = ScrollbarInfo {
731
    width: LayoutWidth::Px(crate::props::basic::pixel::PixelValue::const_px(12)),
732
    padding_left: LayoutPaddingLeft {
733
        inner: crate::props::basic::pixel::PixelValue::const_px(0),
734
    },
735
    padding_right: LayoutPaddingRight {
736
        inner: crate::props::basic::pixel::PixelValue::const_px(0),
737
    },
738
    track: StyleBackgroundContent::Color(ColorU {
739
        r: 241,
740
        g: 241,
741
        b: 241,
742
        a: 255,
743
    }),
744
    thumb: StyleBackgroundContent::Color(ColorU {
745
        r: 130,
746
        g: 130,
747
        b: 130,
748
        a: 255,
749
    }),
750
    button: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
751
    corner: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
752
    resizer: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
753
    clip_to_container_border: false,
754
    scroll_behavior: ScrollBehavior::Auto,
755
    overscroll_behavior_x: OverscrollBehavior::None,
756
    overscroll_behavior_y: OverscrollBehavior::None,
757
    overflow_scrolling: OverflowScrolling::Auto,
758
};
759

            
760
/// A modern scrollbar inspired by Windows 11 (Dark Theme).
761
pub const SCROLLBAR_WINDOWS_DARK: ScrollbarInfo = ScrollbarInfo {
762
    width: LayoutWidth::Px(crate::props::basic::pixel::PixelValue::const_px(12)),
763
    padding_left: LayoutPaddingLeft {
764
        inner: crate::props::basic::pixel::PixelValue::const_px(0),
765
    },
766
    padding_right: LayoutPaddingRight {
767
        inner: crate::props::basic::pixel::PixelValue::const_px(0),
768
    },
769
    track: StyleBackgroundContent::Color(ColorU {
770
        r: 32,
771
        g: 32,
772
        b: 32,
773
        a: 255,
774
    }),
775
    thumb: StyleBackgroundContent::Color(ColorU {
776
        r: 110,
777
        g: 110,
778
        b: 110,
779
        a: 255,
780
    }),
781
    button: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
782
    corner: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
783
    resizer: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
784
    clip_to_container_border: false,
785
    scroll_behavior: ScrollBehavior::Auto,
786
    overscroll_behavior_x: OverscrollBehavior::None,
787
    overscroll_behavior_y: OverscrollBehavior::None,
788
    overflow_scrolling: OverflowScrolling::Auto,
789
};
790

            
791
/// A modern, thin, overlay scrollbar inspired by iOS (Light Theme).
792
pub const SCROLLBAR_IOS_LIGHT: ScrollbarInfo = ScrollbarInfo {
793
    width: LayoutWidth::Px(crate::props::basic::pixel::PixelValue::const_px(7)),
794
    padding_left: LayoutPaddingLeft {
795
        inner: crate::props::basic::pixel::PixelValue::const_px(0),
796
    },
797
    padding_right: LayoutPaddingRight {
798
        inner: crate::props::basic::pixel::PixelValue::const_px(0),
799
    },
800
    track: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
801
    thumb: StyleBackgroundContent::Color(ColorU {
802
        r: 0,
803
        g: 0,
804
        b: 0,
805
        a: 102,
806
    }), // rgba(0,0,0,0.4)
807
    button: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
808
    corner: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
809
    resizer: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
810
    clip_to_container_border: true, // Overlay scrollbars should clip to rounded borders
811
    scroll_behavior: ScrollBehavior::Smooth,
812
    overscroll_behavior_x: OverscrollBehavior::Auto,
813
    overscroll_behavior_y: OverscrollBehavior::Auto,
814
    overflow_scrolling: OverflowScrolling::Auto,
815
};
816

            
817
/// A modern, thin, overlay scrollbar inspired by iOS (Dark Theme).
818
pub const SCROLLBAR_IOS_DARK: ScrollbarInfo = ScrollbarInfo {
819
    width: LayoutWidth::Px(crate::props::basic::pixel::PixelValue::const_px(7)),
820
    padding_left: LayoutPaddingLeft {
821
        inner: crate::props::basic::pixel::PixelValue::const_px(0),
822
    },
823
    padding_right: LayoutPaddingRight {
824
        inner: crate::props::basic::pixel::PixelValue::const_px(0),
825
    },
826
    track: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
827
    thumb: StyleBackgroundContent::Color(ColorU {
828
        r: 255,
829
        g: 255,
830
        b: 255,
831
        a: 102,
832
    }), // rgba(255,255,255,0.4)
833
    button: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
834
    corner: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
835
    resizer: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
836
    clip_to_container_border: true, // Overlay scrollbars should clip to rounded borders
837
    scroll_behavior: ScrollBehavior::Smooth,
838
    overscroll_behavior_x: OverscrollBehavior::Auto,
839
    overscroll_behavior_y: OverscrollBehavior::Auto,
840
    overflow_scrolling: OverflowScrolling::Auto,
841
};
842

            
843
/// A modern, thin, overlay scrollbar inspired by Android (Light Theme).
844
pub const SCROLLBAR_ANDROID_LIGHT: ScrollbarInfo = ScrollbarInfo {
845
    width: LayoutWidth::Px(crate::props::basic::pixel::PixelValue::const_px(6)),
846
    padding_left: LayoutPaddingLeft {
847
        inner: crate::props::basic::pixel::PixelValue::const_px(0),
848
    },
849
    padding_right: LayoutPaddingRight {
850
        inner: crate::props::basic::pixel::PixelValue::const_px(0),
851
    },
852
    track: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
853
    thumb: StyleBackgroundContent::Color(ColorU {
854
        r: 0,
855
        g: 0,
856
        b: 0,
857
        a: 102,
858
    }), // rgba(0,0,0,0.4)
859
    button: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
860
    corner: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
861
    resizer: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
862
    clip_to_container_border: true, // Overlay scrollbars should clip to rounded borders
863
    scroll_behavior: ScrollBehavior::Smooth,
864
    overscroll_behavior_x: OverscrollBehavior::Contain,
865
    overscroll_behavior_y: OverscrollBehavior::Auto,
866
    overflow_scrolling: OverflowScrolling::Auto,
867
};
868

            
869
/// A modern, thin, overlay scrollbar inspired by Android (Dark Theme).
870
pub const SCROLLBAR_ANDROID_DARK: ScrollbarInfo = ScrollbarInfo {
871
    width: LayoutWidth::Px(crate::props::basic::pixel::PixelValue::const_px(6)),
872
    padding_left: LayoutPaddingLeft {
873
        inner: crate::props::basic::pixel::PixelValue::const_px(0),
874
    },
875
    padding_right: LayoutPaddingRight {
876
        inner: crate::props::basic::pixel::PixelValue::const_px(0),
877
    },
878
    track: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
879
    thumb: StyleBackgroundContent::Color(ColorU {
880
        r: 255,
881
        g: 255,
882
        b: 255,
883
        a: 102,
884
    }), // rgba(255,255,255,0.4)
885
    button: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
886
    corner: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
887
    resizer: StyleBackgroundContent::Color(ColorU::TRANSPARENT),
888
    clip_to_container_border: true, // Overlay scrollbars should clip to rounded borders
889
    scroll_behavior: ScrollBehavior::Smooth,
890
    overscroll_behavior_x: OverscrollBehavior::Contain,
891
    overscroll_behavior_y: OverscrollBehavior::Auto,
892
    overflow_scrolling: OverflowScrolling::Auto,
893
};
894

            
895
// --- PARSERS ---
896

            
897
#[derive(Clone, PartialEq, Eq)]
898
pub enum LayoutScrollbarWidthParseError<'a> {
899
    InvalidValue(&'a str),
900
}
901
impl_debug_as_display!(LayoutScrollbarWidthParseError<'a>);
902
impl_display! { LayoutScrollbarWidthParseError<'a>, {
903
    InvalidValue(v) => format!("Invalid scrollbar-width value: \"{}\"", v),
904
}}
905

            
906
#[derive(Debug, Clone, PartialEq, Eq)]
907
#[repr(C, u8)]
908
pub enum LayoutScrollbarWidthParseErrorOwned {
909
    InvalidValue(AzString),
910
}
911
impl LayoutScrollbarWidthParseError<'_> {
912
18
    #[must_use] pub fn to_contained(&self) -> LayoutScrollbarWidthParseErrorOwned {
913
18
        match self {
914
18
            Self::InvalidValue(s) => {
915
18
                LayoutScrollbarWidthParseErrorOwned::InvalidValue((*s).to_string().into())
916
            }
917
        }
918
18
    }
919
}
920
impl LayoutScrollbarWidthParseErrorOwned {
921
18
    #[must_use] pub fn to_shared(&self) -> LayoutScrollbarWidthParseError<'_> {
922
18
        match self {
923
18
            Self::InvalidValue(s) => LayoutScrollbarWidthParseError::InvalidValue(s.as_str()),
924
        }
925
18
    }
926
}
927

            
928
#[cfg(feature = "parser")]
929
/// # Errors
930
///
931
/// Returns an error if `input` is not a valid CSS `scrollbar-width` value.
932
43
pub fn parse_layout_scrollbar_width(
933
43
    input: &str,
934
43
) -> Result<LayoutScrollbarWidth, LayoutScrollbarWidthParseError<'_>> {
935
43
    match input.trim() {
936
43
        "auto" => Ok(LayoutScrollbarWidth::Auto),
937
40
        "thin" => Ok(LayoutScrollbarWidth::Thin),
938
36
        "none" => Ok(LayoutScrollbarWidth::None),
939
33
        _ => Err(LayoutScrollbarWidthParseError::InvalidValue(input)),
940
    }
941
43
}
942

            
943
#[derive(Clone, PartialEq)]
944
pub enum StyleScrollbarColorParseError<'a> {
945
    InvalidValue(&'a str),
946
    Color(CssColorParseError<'a>),
947
}
948
impl_debug_as_display!(StyleScrollbarColorParseError<'a>);
949
impl_display! { StyleScrollbarColorParseError<'a>, {
950
    InvalidValue(v) => format!("Invalid scrollbar-color value: \"{}\"", v),
951
    Color(e) => format!("Invalid color in scrollbar-color: {}", e),
952
}}
953
impl_from!(CssColorParseError<'a>, StyleScrollbarColorParseError::Color);
954

            
955
#[derive(Debug, Clone, PartialEq)]
956
#[repr(C, u8)]
957
pub enum StyleScrollbarColorParseErrorOwned {
958
    InvalidValue(AzString),
959
    Color(CssColorParseErrorOwned),
960
}
961
impl StyleScrollbarColorParseError<'_> {
962
14
    #[must_use] pub fn to_contained(&self) -> StyleScrollbarColorParseErrorOwned {
963
14
        match self {
964
12
            Self::InvalidValue(s) => {
965
12
                StyleScrollbarColorParseErrorOwned::InvalidValue((*s).to_string().into())
966
            }
967
2
            Self::Color(e) => StyleScrollbarColorParseErrorOwned::Color(e.to_contained()),
968
        }
969
14
    }
970
}
971
impl StyleScrollbarColorParseErrorOwned {
972
14
    #[must_use] pub fn to_shared(&self) -> StyleScrollbarColorParseError<'_> {
973
14
        match self {
974
12
            Self::InvalidValue(s) => StyleScrollbarColorParseError::InvalidValue(s.as_str()),
975
2
            Self::Color(e) => StyleScrollbarColorParseError::Color(e.to_shared()),
976
        }
977
14
    }
978
}
979

            
980
#[cfg(feature = "parser")]
981
/// # Errors
982
///
983
/// Returns an error if `input` is not a valid CSS `scrollbar-color` value.
984
61
pub fn parse_style_scrollbar_color(
985
61
    input: &str,
986
61
) -> Result<StyleScrollbarColor, StyleScrollbarColorParseError<'_>> {
987
61
    let input = input.trim();
988
61
    if input == "auto" {
989
4
        return Ok(StyleScrollbarColor::Auto);
990
57
    }
991

            
992
57
    let mut parts = input.split_whitespace();
993
57
    let thumb_str = parts
994
57
        .next()
995
57
        .ok_or(StyleScrollbarColorParseError::InvalidValue(input))?;
996
52
    let track_str = parts
997
52
        .next()
998
52
        .ok_or(StyleScrollbarColorParseError::InvalidValue(input))?;
999

            
32
    if parts.next().is_some() {
5
        return Err(StyleScrollbarColorParseError::InvalidValue(input));
27
    }
27
    let thumb = parse_css_color(thumb_str)?;
13
    let track = parse_css_color(track_str)?;
12
    Ok(StyleScrollbarColor::Custom(ScrollbarColorCustom {
12
        thumb,
12
        track,
12
    }))
61
}
// --- Scrollbar Visibility Mode Parser ---
#[derive(Clone, PartialEq, Eq)]
pub enum ScrollbarVisibilityModeParseError<'a> {
    InvalidValue(&'a str),
}
impl_debug_as_display!(ScrollbarVisibilityModeParseError<'a>);
impl_display! { ScrollbarVisibilityModeParseError<'a>, {
    InvalidValue(v) => format!("Invalid scrollbar-visibility value: \"{}\"", v),
}}
#[derive(Debug, Clone, PartialEq, Eq)]
#[repr(C, u8)]
pub enum ScrollbarVisibilityModeParseErrorOwned {
    InvalidValue(AzString),
}
impl ScrollbarVisibilityModeParseError<'_> {
12
    #[must_use] pub fn to_contained(&self) -> ScrollbarVisibilityModeParseErrorOwned {
12
        match self {
12
            Self::InvalidValue(s) => ScrollbarVisibilityModeParseErrorOwned::InvalidValue((*s).to_string().into()),
        }
12
    }
}
impl ScrollbarVisibilityModeParseErrorOwned {
12
    #[must_use] pub fn to_shared(&self) -> ScrollbarVisibilityModeParseError<'_> {
12
        match self {
12
            Self::InvalidValue(s) => ScrollbarVisibilityModeParseError::InvalidValue(s.as_str()),
        }
12
    }
}
#[cfg(feature = "parser")]
/// # Errors
///
/// Returns an error if `input` is not a valid CSS `scrollbar-visibility-mode` value.
37
pub fn parse_scrollbar_visibility_mode(
37
    input: &str,
37
) -> Result<ScrollbarVisibilityMode, ScrollbarVisibilityModeParseError<'_>> {
37
    match input.trim() {
37
        "always" => Ok(ScrollbarVisibilityMode::Always),
35
        "when-scrolling" => Ok(ScrollbarVisibilityMode::WhenScrolling),
33
        "auto" => Ok(ScrollbarVisibilityMode::Auto),
31
        _ => Err(ScrollbarVisibilityModeParseError::InvalidValue(input)),
    }
37
}
// --- Scrollbar Fade Delay Parser ---
#[derive(Clone, PartialEq, Eq)]
pub enum ScrollbarFadeDelayParseError<'a> {
    InvalidValue(&'a str),
}
impl_debug_as_display!(ScrollbarFadeDelayParseError<'a>);
impl_display! { ScrollbarFadeDelayParseError<'a>, {
    InvalidValue(v) => format!("Invalid scrollbar-fade-delay value: \"{}\"", v),
}}
#[derive(Debug, Clone, PartialEq, Eq)]
#[repr(C, u8)]
pub enum ScrollbarFadeDelayParseErrorOwned {
    InvalidValue(AzString),
}
impl ScrollbarFadeDelayParseError<'_> {
12
    #[must_use] pub fn to_contained(&self) -> ScrollbarFadeDelayParseErrorOwned {
12
        match self {
12
            Self::InvalidValue(s) => ScrollbarFadeDelayParseErrorOwned::InvalidValue((*s).to_string().into()),
        }
12
    }
}
impl ScrollbarFadeDelayParseErrorOwned {
12
    #[must_use] pub fn to_shared(&self) -> ScrollbarFadeDelayParseError<'_> {
12
        match self {
12
            Self::InvalidValue(s) => ScrollbarFadeDelayParseError::InvalidValue(s.as_str()),
        }
12
    }
}
/// `scrollbar-fade-delay` / `scrollbar-fade-duration` are stored as a plain
/// millisecond `u32`, so a `t` (tick) value has to be converted here rather than
/// carried. `CssDuration::millis` does that at the nominal frame rate — reading
/// `d.inner` directly would hand a FRAME COUNT to a field every consumer reads as
/// milliseconds (`5t` would silently become 5ms, a 16x error).
#[cfg(feature = "parser")]
130
fn parse_time_ms(input: &str) -> Option<u32> {
130
    crate::props::basic::time::parse_duration(input).ok().map(|d| d.millis())
130
}
#[cfg(feature = "parser")]
/// # Errors
///
/// Returns an error if `input` is not a valid CSS `scrollbar-fade-delay` value.
42
pub fn parse_scrollbar_fade_delay(
42
    input: &str,
42
) -> Result<ScrollbarFadeDelay, ScrollbarFadeDelayParseError<'_>> {
42
    parse_time_ms(input)
42
        .map(ScrollbarFadeDelay::new)
42
        .ok_or(ScrollbarFadeDelayParseError::InvalidValue(input))
42
}
// --- Scrollbar Fade Duration Parser ---
#[derive(Clone, PartialEq, Eq)]
pub enum ScrollbarFadeDurationParseError<'a> {
    InvalidValue(&'a str),
}
impl_debug_as_display!(ScrollbarFadeDurationParseError<'a>);
impl_display! { ScrollbarFadeDurationParseError<'a>, {
    InvalidValue(v) => format!("Invalid scrollbar-fade-duration value: \"{}\"", v),
}}
#[derive(Debug, Clone, PartialEq, Eq)]
#[repr(C, u8)]
pub enum ScrollbarFadeDurationParseErrorOwned {
    InvalidValue(AzString),
}
impl ScrollbarFadeDurationParseError<'_> {
12
    #[must_use] pub fn to_contained(&self) -> ScrollbarFadeDurationParseErrorOwned {
12
        match self {
12
            Self::InvalidValue(s) => ScrollbarFadeDurationParseErrorOwned::InvalidValue((*s).to_string().into()),
        }
12
    }
}
impl ScrollbarFadeDurationParseErrorOwned {
12
    #[must_use] pub fn to_shared(&self) -> ScrollbarFadeDurationParseError<'_> {
12
        match self {
12
            Self::InvalidValue(s) => ScrollbarFadeDurationParseError::InvalidValue(s.as_str()),
        }
12
    }
}
#[cfg(feature = "parser")]
/// # Errors
///
/// Returns an error if `input` is not a valid CSS `scrollbar-fade-duration` value.
39
pub fn parse_scrollbar_fade_duration(
39
    input: &str,
39
) -> Result<ScrollbarFadeDuration, ScrollbarFadeDurationParseError<'_>> {
39
    parse_time_ms(input)
39
        .map(ScrollbarFadeDuration::new)
39
        .ok_or(ScrollbarFadeDurationParseError::InvalidValue(input))
39
}
#[cfg(all(test, feature = "parser"))]
mod tests {
    use super::*;
    use crate::props::basic::color::ColorU;
    #[test]
1
    fn test_parse_scrollbar_width() {
1
        assert_eq!(
1
            parse_layout_scrollbar_width("auto").unwrap(),
            LayoutScrollbarWidth::Auto
        );
1
        assert_eq!(
1
            parse_layout_scrollbar_width("thin").unwrap(),
            LayoutScrollbarWidth::Thin
        );
1
        assert_eq!(
1
            parse_layout_scrollbar_width("none").unwrap(),
            LayoutScrollbarWidth::None
        );
1
        assert!(parse_layout_scrollbar_width("thick").is_err());
1
    }
    #[test]
1
    fn test_parse_scrollbar_color() {
1
        assert_eq!(
1
            parse_style_scrollbar_color("auto").unwrap(),
            StyleScrollbarColor::Auto
        );
1
        let custom = parse_style_scrollbar_color("red blue").unwrap();
1
        assert_eq!(
            custom,
            StyleScrollbarColor::Custom(ScrollbarColorCustom {
                thumb: ColorU::RED,
                track: ColorU::BLUE
            })
        );
1
        let custom_hex = parse_style_scrollbar_color("#ff0000 #0000ff").unwrap();
1
        assert_eq!(
            custom_hex,
            StyleScrollbarColor::Custom(ScrollbarColorCustom {
                thumb: ColorU::RED,
                track: ColorU::BLUE
            })
        );
1
        assert!(parse_style_scrollbar_color("red").is_err());
1
        assert!(parse_style_scrollbar_color("red blue green").is_err());
1
    }
}
#[cfg(test)]
#[allow(clippy::unreadable_literal, clippy::float_cmp)]
mod autotest_generated {
    use super::*;
    use crate::codegen::format::FormatAsRustCode;
    /// Largest integer an `f32` represents exactly (`2^24`). Every millisecond
    /// count at or below this survives the `f32` hop inside `parse_duration`;
    /// above it, neighbouring `f32`s are more than 1ms apart.
    #[cfg(feature = "parser")]
    const TWO_POW_24: u32 = 16_777_216;
    /// Inputs that must never parse as anything, whatever the property.
    #[cfg(feature = "parser")]
    const GARBAGE: &[&str] = &[
        "",
        " ",
        "   ",
        "\t\n",
        "\u{a0}",          // non-breaking space (trimmed away -> empty)
        "\0",
        "\u{1F600}",       // emoji
        "e\u{0301}",       // combining acute accent
        "\u{202e}auto",    // RTL override prefix
        "аuto",            // Cyrillic 'а' homoglyph
        "AUTO",
        "auto;",
        "auto garbage",
        "-1",
        "NaN",
        "inf",
        "0x10",
        "9223372036854775807", // i64::MAX
        "1e400",
        "{[(<",
    ];
    // ======================================================================
    // ScrollPhysics presets  (other)
    // ======================================================================
    /// Every preset must be usable as-is by a scroll animator: no NaN/inf can
    /// reach the physics integrator, the deceleration rate has to stay strictly
    /// below 1.0 (at 1.0 momentum never decays -> the scroll timer never stops),
    /// and the timer tick must be non-zero (a 0ms tick is a busy-loop).
    fn assert_physics_invariants(p: ScrollPhysics, name: &str) {
        for (field, v) in [
            ("deceleration_rate", p.deceleration_rate),
            ("min_velocity_threshold", p.min_velocity_threshold),
            ("max_velocity", p.max_velocity),
            ("wheel_multiplier", p.wheel_multiplier),
            ("overscroll_elasticity", p.overscroll_elasticity),
            ("max_overscroll_distance", p.max_overscroll_distance),
        ] {
            assert!(v.is_finite(), "{name}.{field} is not finite: {v}");
            assert!(!v.is_nan(), "{name}.{field} is NaN");
            assert!(v >= 0.0, "{name}.{field} is negative: {v}");
        }
        assert!(
            p.deceleration_rate > 0.0 && p.deceleration_rate < 1.0,
            "{name}.deceleration_rate must stay inside (0.0, 1.0) or momentum never stops: {}",
            p.deceleration_rate
        );
        assert!(
            (0.0..=1.0).contains(&p.overscroll_elasticity),
            "{name}.overscroll_elasticity out of [0.0, 1.0]: {}",
            p.overscroll_elasticity
        );
        assert!(
            p.max_velocity > p.min_velocity_threshold,
            "{name}: max_velocity ({}) must exceed min_velocity_threshold ({})",
            p.max_velocity,
            p.min_velocity_threshold
        );
        assert!(
            p.wheel_multiplier > 0.0,
            "{name}.wheel_multiplier must be > 0 or the wheel does nothing"
        );
        assert!(
            p.timer_interval_ms > 0,
            "{name}.timer_interval_ms == 0 would spin the physics timer"
        );
        assert!(
            p.smooth_scroll_duration_ms > 0,
            "{name}.smooth_scroll_duration_ms == 0 makes `scroll-behavior: smooth` a no-op"
        );
    }
    #[test]
    fn scroll_physics_presets_hold_their_invariants() {
        assert_physics_invariants(ScrollPhysics::default(), "default");
        assert_physics_invariants(ScrollPhysics::ios(), "ios");
        assert_physics_invariants(ScrollPhysics::macos(), "macos");
        assert_physics_invariants(ScrollPhysics::windows(), "windows");
        assert_physics_invariants(ScrollPhysics::android(), "android");
    }
    #[test]
    fn scroll_physics_presets_are_pure_and_distinct() {
        // Called twice: no interior state, no drift.
        assert_eq!(ScrollPhysics::ios(), ScrollPhysics::ios());
        assert_eq!(ScrollPhysics::windows(), ScrollPhysics::windows());
        // A preset that silently equals another would mean a copy/paste bug.
        assert_ne!(ScrollPhysics::ios(), ScrollPhysics::macos());
        assert_ne!(ScrollPhysics::ios(), ScrollPhysics::android());
        assert_ne!(ScrollPhysics::macos(), ScrollPhysics::windows());
        assert_ne!(ScrollPhysics::android(), ScrollPhysics::windows());
        assert_ne!(ScrollPhysics::default(), ScrollPhysics::ios());
    }
    /// The documented platform character of each preset, asserted rather than
    /// assumed: Windows must not bounce, iOS/macOS must scroll naturally.
    #[test]
    fn scroll_physics_presets_match_their_documented_platform_behavior() {
        let win = ScrollPhysics::windows();
        assert_eq!(win.overscroll_elasticity, 0.0);
        assert_eq!(win.max_overscroll_distance, 0.0);
        assert!(!win.invert_direction);
        assert!(ScrollPhysics::ios().invert_direction);
        assert!(ScrollPhysics::macos().invert_direction);
        assert!(!ScrollPhysics::android().invert_direction);
        // iOS is the "slowest to stop" of the presets.
        assert!(ScrollPhysics::ios().deceleration_rate > ScrollPhysics::windows().deceleration_rate);
        // The default is Windows-like: no bounce.
        assert_eq!(ScrollPhysics::default().overscroll_elasticity, 0.0);
    }
    // ======================================================================
    // ScrollbarFadeDelay::new / ScrollbarFadeDuration::new  (constructors)
    // ======================================================================
    #[test]
    fn fade_delay_and_duration_constructors_store_their_argument_verbatim() {
        for ms in [0u32, 1, 16, 500, u32::MAX / 2, u32::MAX - 1, u32::MAX] {
            assert_eq!(ScrollbarFadeDelay::new(ms).ms, ms);
            assert_eq!(ScrollbarFadeDuration::new(ms).ms, ms);
        }
    }
    #[test]
    fn fade_zero_constants_agree_with_new_and_default() {
        assert_eq!(ScrollbarFadeDelay::ZERO, ScrollbarFadeDelay::new(0));
        assert_eq!(ScrollbarFadeDelay::ZERO, ScrollbarFadeDelay::default());
        assert_eq!(ScrollbarFadeDuration::ZERO, ScrollbarFadeDuration::new(0));
        assert_eq!(ScrollbarFadeDuration::ZERO, ScrollbarFadeDuration::default());
        assert_eq!(ScrollbarFadeDelay::ZERO.ms, 0);
        assert_eq!(ScrollbarFadeDuration::ZERO.ms, 0);
    }
    /// The derived `Ord` must order by milliseconds, not by declaration order of
    /// some future field, otherwise "fades sooner" comparisons invert.
    #[test]
    fn fade_delay_orders_by_millisecond_count() {
        assert!(ScrollbarFadeDelay::new(0) < ScrollbarFadeDelay::new(1));
        assert!(ScrollbarFadeDelay::new(499) < ScrollbarFadeDelay::new(500));
        assert!(ScrollbarFadeDelay::new(u32::MAX) > ScrollbarFadeDelay::new(u32::MAX - 1));
        assert!(ScrollbarFadeDuration::new(0) < ScrollbarFadeDuration::new(u32::MAX));
    }
    // ======================================================================
    // print_as_css_value  (encoders)
    // ======================================================================
    #[test]
    fn enum_printers_emit_the_css_keywords() {
        assert_eq!(ScrollBehavior::Auto.print_as_css_value(), "auto");
        assert_eq!(ScrollBehavior::Smooth.print_as_css_value(), "smooth");
        assert_eq!(ScrollBehavior::default(), ScrollBehavior::Auto);
        assert_eq!(OverscrollBehavior::Auto.print_as_css_value(), "auto");
        assert_eq!(OverscrollBehavior::Contain.print_as_css_value(), "contain");
        assert_eq!(OverscrollBehavior::None.print_as_css_value(), "none");
        assert_eq!(OverscrollBehavior::default(), OverscrollBehavior::Auto);
        assert_eq!(OverflowScrolling::Auto.print_as_css_value(), "auto");
        assert_eq!(OverflowScrolling::Touch.print_as_css_value(), "touch");
        assert_eq!(OverflowScrolling::default(), OverflowScrolling::Auto);
        assert_eq!(LayoutScrollbarWidth::Auto.print_as_css_value(), "auto");
        assert_eq!(LayoutScrollbarWidth::Thin.print_as_css_value(), "thin");
        assert_eq!(LayoutScrollbarWidth::None.print_as_css_value(), "none");
        assert_eq!(LayoutScrollbarWidth::default(), LayoutScrollbarWidth::Auto);
        assert_eq!(ScrollbarVisibilityMode::Always.print_as_css_value(), "always");
        assert_eq!(
            ScrollbarVisibilityMode::WhenScrolling.print_as_css_value(),
            "when-scrolling"
        );
        assert_eq!(ScrollbarVisibilityMode::Auto.print_as_css_value(), "auto");
        assert_eq!(
            ScrollbarVisibilityMode::default(),
            ScrollbarVisibilityMode::Always
        );
    }
    /// `0` is printed unit-less (a bare `0` is legal CSS for a time), everything
    /// else carries the `ms` unit — dropping the unit on a non-zero value would
    /// emit invalid CSS.
    #[test]
    fn fade_printers_special_case_zero_and_keep_the_unit_otherwise() {
        assert_eq!(ScrollbarFadeDelay::new(0).print_as_css_value(), "0");
        assert_eq!(ScrollbarFadeDelay::new(1).print_as_css_value(), "1ms");
        assert_eq!(ScrollbarFadeDelay::new(500).print_as_css_value(), "500ms");
        assert_eq!(
            ScrollbarFadeDelay::new(u32::MAX).print_as_css_value(),
            "4294967295ms"
        );
        assert_eq!(ScrollbarFadeDuration::new(0).print_as_css_value(), "0");
        assert_eq!(ScrollbarFadeDuration::new(200).print_as_css_value(), "200ms");
        assert_eq!(
            ScrollbarFadeDuration::new(u32::MAX).print_as_css_value(),
            "4294967295ms"
        );
    }
    #[test]
    fn scrollbar_color_printer_emits_two_eight_digit_hashes() {
        assert_eq!(StyleScrollbarColor::Auto.print_as_css_value(), "auto");
        assert_eq!(StyleScrollbarColor::default(), StyleScrollbarColor::Auto);
        let custom = StyleScrollbarColor::Custom(ScrollbarColorCustom {
            thumb: ColorU::RED,
            track: ColorU::TRANSPARENT,
        });
        assert_eq!(custom.print_as_css_value(), "#ff0000ff #00000000");
    }
    /// The aggregate printers are non-standard debug formats; they must at least
    /// not panic and must include both sub-scrollbars.
    #[test]
    fn aggregate_printers_do_not_panic_and_mention_both_axes() {
        let printed = ScrollbarStyle::default().print_as_css_value();
        assert!(printed.contains("horz("), "{printed}");
        assert!(printed.contains("vert("), "{printed}");
        let info = ScrollbarInfo::default().print_as_css_value();
        assert!(info.contains("width:"), "{info}");
        assert!(info.contains("thumb:"), "{info}");
        assert!(info.contains("resizer:"), "{info}");
    }
    // ======================================================================
    // FormatAsRustCode  (codegen encoders)
    // ======================================================================
    #[test]
    fn format_as_rust_code_emits_constructible_expressions() {
        assert_eq!(
            LayoutScrollbarWidth::Thin.format_as_rust_code(0),
            "LayoutScrollbarWidth::Thin"
        );
        assert_eq!(
            LayoutScrollbarWidth::None.format_as_rust_code(7),
            "LayoutScrollbarWidth::None",
            "indent depth must not leak into a unit-variant literal"
        );
        assert_eq!(
            ScrollbarVisibilityMode::WhenScrolling.format_as_rust_code(0),
            "ScrollbarVisibilityMode::WhenScrolling"
        );
        assert_eq!(
            StyleScrollbarColor::Auto.format_as_rust_code(0),
            "StyleScrollbarColor::Auto"
        );
        // The `new(..)` codegen must round-trip the exact u32, including the extremes.
        assert_eq!(
            ScrollbarFadeDelay::new(0).format_as_rust_code(0),
            "ScrollbarFadeDelay::new(0)"
        );
        assert_eq!(
            ScrollbarFadeDelay::new(u32::MAX).format_as_rust_code(3),
            "ScrollbarFadeDelay::new(4294967295)"
        );
        assert_eq!(
            ScrollbarFadeDuration::new(u32::MAX).format_as_rust_code(0),
            "ScrollbarFadeDuration::new(4294967295)"
        );
    }
    #[test]
    fn format_as_rust_code_of_aggregates_does_not_panic() {
        let custom = StyleScrollbarColor::Custom(ScrollbarColorCustom {
            thumb: ColorU::TRANSPARENT,
            track: ColorU::WHITE,
        })
        .format_as_rust_code(0);
        assert!(
            custom.starts_with("StyleScrollbarColor::Custom(ScrollbarColorCustom {"),
            "{custom}"
        );
        assert!(custom.contains("thumb:") && custom.contains("track:"), "{custom}");
        for tabs in [0usize, 1, 4] {
            let code = ScrollbarStyle::default().format_as_rust_code(tabs);
            assert!(code.starts_with("ScrollbarStyle {"), "{code}");
            assert!(code.contains("horizontal:"), "{code}");
            assert!(code.contains("vertical:"), "{code}");
        }
    }
    // ======================================================================
    // Defaults / constants  (invariants)
    // ======================================================================
    #[test]
    fn scrollbar_info_default_is_the_classic_light_constant() {
        assert_eq!(ScrollbarInfo::default(), SCROLLBAR_CLASSIC_LIGHT);
        let style = ScrollbarStyle::default();
        assert_eq!(style.horizontal, SCROLLBAR_CLASSIC_LIGHT);
        assert_eq!(style.vertical, SCROLLBAR_CLASSIC_LIGHT);
    }
    /// `ComputedScrollbarStyle::default()` reads its colors out of the default
    /// `ScrollbarInfo`; if the default track/thumb ever became a gradient the
    /// `match` would silently fall through to `None` (UA default) instead.
    #[test]
    fn computed_default_mirrors_the_default_scrollbar_info() {
        let computed = ComputedScrollbarStyle::default();
        let info = ScrollbarInfo::default();
        assert_eq!(computed.width, Some(info.width));
        assert_eq!(
            computed.thumb_color,
            Some(ColorU { r: 193, g: 193, b: 193, a: 255 })
        );
        assert_eq!(
            computed.track_color,
            Some(ColorU { r: 241, g: 241, b: 241, a: 255 })
        );
        assert!(
            computed.thumb_color.is_some() && computed.track_color.is_some(),
            "the classic-light default must resolve to solid colors, not None"
        );
    }
    /// Overlay presets must clip to the container border, classic (space-reserving)
    /// ones must not — the flag is what decides whether the bar is drawn inside
    /// rounded corners.
    #[test]
    fn preset_constants_agree_on_the_overlay_clipping_flag() {
        for info in [SCROLLBAR_CLASSIC_LIGHT, SCROLLBAR_CLASSIC_DARK] {
            assert!(!info.clip_to_container_border);
            assert_eq!(info.scroll_behavior, ScrollBehavior::Auto);
        }
        for info in [
            SCROLLBAR_MACOS_LIGHT,
            SCROLLBAR_MACOS_DARK,
            SCROLLBAR_IOS_LIGHT,
            SCROLLBAR_IOS_DARK,
            SCROLLBAR_ANDROID_LIGHT,
            SCROLLBAR_ANDROID_DARK,
        ] {
            assert!(info.clip_to_container_border);
            assert_eq!(info.scroll_behavior, ScrollBehavior::Smooth);
        }
        for info in [SCROLLBAR_WINDOWS_LIGHT, SCROLLBAR_WINDOWS_DARK] {
            assert!(!info.clip_to_container_border);
            assert_eq!(info.overscroll_behavior_x, OverscrollBehavior::None);
            assert_eq!(info.overscroll_behavior_y, OverscrollBehavior::None);
        }
    }
    /// Light and dark variants of the same platform must differ only in color,
    /// never in geometry — a width drift would make theme switching relayout.
    #[test]
    fn light_and_dark_presets_share_their_geometry() {
        for (light, dark) in [
            (SCROLLBAR_CLASSIC_LIGHT, SCROLLBAR_CLASSIC_DARK),
            (SCROLLBAR_MACOS_LIGHT, SCROLLBAR_MACOS_DARK),
            (SCROLLBAR_WINDOWS_LIGHT, SCROLLBAR_WINDOWS_DARK),
            (SCROLLBAR_IOS_LIGHT, SCROLLBAR_IOS_DARK),
            (SCROLLBAR_ANDROID_LIGHT, SCROLLBAR_ANDROID_DARK),
        ] {
            assert_eq!(light.width, dark.width);
            assert_eq!(light.padding_left, dark.padding_left);
            assert_eq!(light.padding_right, dark.padding_right);
            assert_eq!(light.clip_to_container_border, dark.clip_to_container_border);
            assert_ne!(light.thumb, dark.thumb, "light/dark thumbs must differ");
        }
    }
    // ======================================================================
    // parse_layout_scrollbar_width  (parser)
    // ======================================================================
    #[cfg(feature = "parser")]
    #[test]
    fn scrollbar_width_parses_the_three_legal_keywords() {
        assert_eq!(
            parse_layout_scrollbar_width("auto"),
            Ok(LayoutScrollbarWidth::Auto)
        );
        assert_eq!(
            parse_layout_scrollbar_width("thin"),
            Ok(LayoutScrollbarWidth::Thin)
        );
        assert_eq!(
            parse_layout_scrollbar_width("none"),
            Ok(LayoutScrollbarWidth::None)
        );
    }
    #[cfg(feature = "parser")]
    #[test]
    fn scrollbar_width_trims_surrounding_whitespace_but_rejects_inner_junk() {
        assert_eq!(
            parse_layout_scrollbar_width("  \t thin \n "),
            Ok(LayoutScrollbarWidth::Thin)
        );
        assert!(parse_layout_scrollbar_width("thin;").is_err());
        assert!(parse_layout_scrollbar_width("thin thin").is_err());
        assert!(parse_layout_scrollbar_width("th in").is_err());
    }
    /// Keyword matching is byte-exact: CSS keywords are case-insensitive in the
    /// spec, so an upper-case `AUTO` being rejected here is a real (if minor)
    /// conformance gap. Pinned so a future fix is a deliberate change.
    #[cfg(feature = "parser")]
    #[test]
    fn scrollbar_width_keyword_matching_is_case_sensitive() {
        assert!(parse_layout_scrollbar_width("AUTO").is_err());
        assert!(parse_layout_scrollbar_width("Thin").is_err());
        assert!(parse_layout_scrollbar_width("NONE").is_err());
    }
    #[cfg(feature = "parser")]
    #[test]
    fn scrollbar_width_rejects_every_garbage_input_without_panicking() {
        for input in GARBAGE {
            assert!(
                parse_layout_scrollbar_width(input).is_err(),
                "expected {input:?} to be rejected"
            );
        }
    }
    /// The error must carry the caller's *untrimmed* slice so diagnostics can
    /// point back at the original source text.
    #[cfg(feature = "parser")]
    #[test]
    fn scrollbar_width_error_keeps_the_raw_untrimmed_input() {
        let raw = "  thick  ";
        assert_eq!(
            parse_layout_scrollbar_width(raw),
            Err(LayoutScrollbarWidthParseError::InvalidValue(raw))
        );
        let msg = format!("{}", parse_layout_scrollbar_width(raw).unwrap_err());
        assert!(msg.contains(raw), "{msg}");
    }
    #[cfg(feature = "parser")]
    #[test]
    fn scrollbar_width_survives_a_megabyte_of_input_and_deep_nesting() {
        let huge = "a".repeat(1_000_000);
        assert!(parse_layout_scrollbar_width(&huge).is_err());
        let repeated_token = "auto".repeat(250_000);
        assert!(parse_layout_scrollbar_width(&repeated_token).is_err());
        let nested = "(".repeat(10_000);
        assert!(parse_layout_scrollbar_width(&nested).is_err());
    }
    #[cfg(feature = "parser")]
    #[test]
    fn scrollbar_width_round_trips_through_its_printer() {
        for value in [
            LayoutScrollbarWidth::Auto,
            LayoutScrollbarWidth::Thin,
            LayoutScrollbarWidth::None,
        ] {
            let encoded = value.print_as_css_value();
            assert_eq!(
                parse_layout_scrollbar_width(&encoded),
                Ok(value),
                "{encoded} did not decode back to {value:?}"
            );
        }
    }
    // ======================================================================
    // parse_style_scrollbar_color  (parser)
    // ======================================================================
    #[cfg(feature = "parser")]
    #[test]
    fn scrollbar_color_needs_exactly_two_colors_or_the_auto_keyword() {
        assert_eq!(parse_style_scrollbar_color("auto"), Ok(StyleScrollbarColor::Auto));
        assert_eq!(
            parse_style_scrollbar_color("  auto  "),
            Ok(StyleScrollbarColor::Auto)
        );
        assert_eq!(
            parse_style_scrollbar_color("red blue"),
            Ok(StyleScrollbarColor::Custom(ScrollbarColorCustom {
                thumb: ColorU::RED,
                track: ColorU::BLUE,
            }))
        );
        // Too few / too many components: rejected as InvalidValue, not as a color error.
        for input in ["red", "#fff", "red blue green", "a b c d"] {
            assert!(
                matches!(
                    parse_style_scrollbar_color(input),
                    Err(StyleScrollbarColorParseError::InvalidValue(_))
                ),
                "expected {input:?} to be an InvalidValue error"
            );
        }
    }
    /// Component splitting is on *any* whitespace run, so tabs, newlines and
    /// repeated spaces are all legal separators.
    #[cfg(feature = "parser")]
    #[test]
    fn scrollbar_color_accepts_any_whitespace_run_as_the_separator() {
        let expected = StyleScrollbarColor::Custom(ScrollbarColorCustom {
            thumb: ColorU::RED,
            track: ColorU::BLUE,
        });
        assert_eq!(parse_style_scrollbar_color("red\tblue"), Ok(expected));
        assert_eq!(parse_style_scrollbar_color("red\n blue"), Ok(expected));
        assert_eq!(parse_style_scrollbar_color("  red     blue  "), Ok(expected));
    }
    /// Color *names* are case-insensitive (the color parser lowercases), unlike
    /// the `auto` keyword right above it, which is compared verbatim.
    #[cfg(feature = "parser")]
    #[test]
    fn scrollbar_color_names_are_case_insensitive_but_the_auto_keyword_is_not() {
        assert_eq!(
            parse_style_scrollbar_color("RED BLUE"),
            Ok(StyleScrollbarColor::Custom(ScrollbarColorCustom {
                thumb: ColorU::RED,
                track: ColorU::BLUE,
            }))
        );
        // "AUTO" is a single token -> not the auto keyword, and not two colors.
        assert!(matches!(
            parse_style_scrollbar_color("AUTO"),
            Err(StyleScrollbarColorParseError::InvalidValue(_))
        ));
        // ...and `auto` is not a named color either, so it cannot sneak in as one.
        assert!(matches!(
            parse_style_scrollbar_color("auto auto"),
            Err(StyleScrollbarColorParseError::Color(_))
        ));
    }
    /// Whitespace-splitting happens *before* the color parser runs, so a
    /// functional color with spaces after its commas is torn into pieces.
    /// `rgb(255, 0, 0) blue` is valid CSS but is rejected here; the space-free
    /// spelling works. Pinned as a known limitation.
    #[cfg(feature = "parser")]
    #[test]
    fn scrollbar_color_rejects_functional_colors_containing_spaces() {
        assert_eq!(
            parse_style_scrollbar_color("rgb(255,0,0) blue"),
            Ok(StyleScrollbarColor::Custom(ScrollbarColorCustom {
                thumb: ColorU::RED,
                track: ColorU::BLUE,
            }))
        );
        assert!(matches!(
            parse_style_scrollbar_color("rgb(255, 0, 0) blue"),
            Err(StyleScrollbarColorParseError::InvalidValue(_))
        ));
    }
    #[cfg(feature = "parser")]
    #[test]
    fn scrollbar_color_reports_which_component_failed() {
        // A bad thumb is reported as a color error, not as InvalidValue.
        assert!(matches!(
            parse_style_scrollbar_color("notacolor blue"),
            Err(StyleScrollbarColorParseError::Color(_))
        ));
        assert!(matches!(
            parse_style_scrollbar_color("red notacolor"),
            Err(StyleScrollbarColorParseError::Color(_))
        ));
        assert!(matches!(
            parse_style_scrollbar_color("#gggggg #000000"),
            Err(StyleScrollbarColorParseError::Color(_))
        ));
    }
    #[cfg(feature = "parser")]
    #[test]
    fn scrollbar_color_rejects_garbage_without_panicking() {
        for input in GARBAGE {
            assert!(
                parse_style_scrollbar_color(input).is_err(),
                "expected {input:?} to be rejected"
            );
        }
        // Boundary numerics as color components.
        for input in [
            "0 0",
            "-0 -0",
            "NaN NaN",
            "inf inf",
            "9223372036854775807 1",
            "1e400 1e400",
            "-1 -1",
        ] {
            assert!(
                parse_style_scrollbar_color(input).is_err(),
                "expected {input:?} to be rejected"
            );
        }
    }
    #[cfg(feature = "parser")]
    #[test]
    fn scrollbar_color_survives_huge_and_deeply_nested_input() {
        let huge = "z".repeat(500_000);
        let two_huge = format!("{huge} {huge}");
        assert!(parse_style_scrollbar_color(&two_huge).is_err());
        let nested = "(".repeat(10_000);
        assert!(parse_style_scrollbar_color(&format!("{nested} {nested}")).is_err());
        // Many components: must be rejected on count, not walked color-by-color.
        let many = "red ".repeat(100_000);
        assert!(matches!(
            parse_style_scrollbar_color(&many),
            Err(StyleScrollbarColorParseError::InvalidValue(_))
        ));
    }
    /// The color error carries the *trimmed* input (the function rebinds `input`
    /// to the trimmed slice), unlike `parse_layout_scrollbar_width`, which keeps
    /// the raw slice. Pinned so the inconsistency is visible.
    #[cfg(feature = "parser")]
    #[test]
    fn scrollbar_color_error_carries_the_trimmed_input() {
        assert_eq!(
            parse_style_scrollbar_color("  red  "),
            Err(StyleScrollbarColorParseError::InvalidValue("red"))
        );
    }
    #[cfg(feature = "parser")]
    #[test]
    fn scrollbar_color_round_trips_through_its_printer() {
        let samples = [
            StyleScrollbarColor::Auto,
            StyleScrollbarColor::Custom(ScrollbarColorCustom {
                thumb: ColorU::RED,
                track: ColorU::BLUE,
            }),
            StyleScrollbarColor::Custom(ScrollbarColorCustom {
                thumb: ColorU::TRANSPARENT,
                track: ColorU::TRANSPARENT,
            }),
            StyleScrollbarColor::Custom(ScrollbarColorCustom {
                thumb: ColorU { r: 0, g: 0, b: 0, a: 100 },
                track: ColorU { r: 1, g: 2, b: 3, a: 4 },
            }),
            StyleScrollbarColor::Custom(ScrollbarColorCustom {
                thumb: ColorU::WHITE,
                track: ColorU::BLACK,
            }),
        ];
        for value in samples {
            let encoded = value.print_as_css_value();
            assert_eq!(
                parse_style_scrollbar_color(&encoded),
                Ok(value),
                "{encoded} did not decode back to {value:?}"
            );
        }
    }
    // ======================================================================
    // parse_scrollbar_visibility_mode  (parser)
    // ======================================================================
    #[cfg(feature = "parser")]
    #[test]
    fn visibility_mode_parses_its_three_keywords_and_trims() {
        assert_eq!(
            parse_scrollbar_visibility_mode("always"),
            Ok(ScrollbarVisibilityMode::Always)
        );
        assert_eq!(
            parse_scrollbar_visibility_mode(" when-scrolling\t"),
            Ok(ScrollbarVisibilityMode::WhenScrolling)
        );
        assert_eq!(
            parse_scrollbar_visibility_mode("auto"),
            Ok(ScrollbarVisibilityMode::Auto)
        );
    }
    #[cfg(feature = "parser")]
    #[test]
    fn visibility_mode_rejects_near_misses_and_garbage() {
        for input in [
            "when scrolling", // space instead of hyphen
            "whenscrolling",
            "when-scrolling-",
            "-when-scrolling",
            "ALWAYS",
            "always;",
            "always auto",
        ] {
            assert!(
                parse_scrollbar_visibility_mode(input).is_err(),
                "expected {input:?} to be rejected"
            );
        }
        for input in GARBAGE {
            assert!(
                parse_scrollbar_visibility_mode(input).is_err(),
                "expected {input:?} to be rejected"
            );
        }
    }
    #[cfg(feature = "parser")]
    #[test]
    fn visibility_mode_survives_huge_and_nested_input() {
        assert!(parse_scrollbar_visibility_mode(&"a".repeat(1_000_000)).is_err());
        assert!(parse_scrollbar_visibility_mode(&"always".repeat(200_000)).is_err());
        assert!(parse_scrollbar_visibility_mode(&"[".repeat(10_000)).is_err());
    }
    #[cfg(feature = "parser")]
    #[test]
    fn visibility_mode_round_trips_through_its_printer() {
        for value in [
            ScrollbarVisibilityMode::Always,
            ScrollbarVisibilityMode::WhenScrolling,
            ScrollbarVisibilityMode::Auto,
        ] {
            let encoded = value.print_as_css_value();
            assert_eq!(
                parse_scrollbar_visibility_mode(&encoded),
                Ok(value),
                "{encoded} did not decode back to {value:?}"
            );
        }
    }
    // ======================================================================
    // parse_time_ms  (private parser)
    // ======================================================================
    #[cfg(feature = "parser")]
    #[test]
    fn parse_time_ms_accepts_bare_zero_and_both_units() {
        assert_eq!(parse_time_ms("0"), Some(0));
        assert_eq!(parse_time_ms("0ms"), Some(0));
        assert_eq!(parse_time_ms("0s"), Some(0));
        assert_eq!(parse_time_ms("500ms"), Some(500));
        assert_eq!(parse_time_ms("1s"), Some(1000));
        assert_eq!(parse_time_ms("1.5s"), Some(1500));
        assert_eq!(parse_time_ms("  200ms  "), Some(200));
        assert_eq!(parse_time_ms("200MS"), Some(200), "units are case-insensitive");
    }
    /// The scrollbar fade fields are a bare millisecond `u32`, so a `t` (tick)
    /// value has to be CONVERTED at the nominal frame rate on the way in, not
    /// passed through. `60t` is one second; passing the raw tick count through
    /// would make it 60ms.
    #[cfg(feature = "parser")]
    #[test]
    fn parse_time_ms_converts_the_tick_unit_to_milliseconds() {
        assert_eq!(parse_time_ms("60t"), Some(1000));
        assert_eq!(parse_time_ms("30t"), Some(500));
        assert_eq!(parse_time_ms("1t"), Some(16));
        assert_eq!(parse_time_ms("0t"), Some(0));
        assert_ne!(parse_time_ms("60t"), Some(60), "ticks passed through as ms");
    }
    /// A unit is mandatory (except for a bare `0`) and must be attached to the
    /// number — `"1 s"` has an interior space and cannot parse.
    #[cfg(feature = "parser")]
    #[test]
    fn parse_time_ms_requires_an_attached_unit() {
        assert_eq!(parse_time_ms("500"), None);
        assert_eq!(parse_time_ms("1 s"), None);
        assert_eq!(parse_time_ms("500 ms"), None);
        assert_eq!(parse_time_ms("ms"), None);
        assert_eq!(parse_time_ms("s"), None);
        assert_eq!(parse_time_ms("500px"), None);
        assert_eq!(parse_time_ms("500msms"), None);
    }
    #[cfg(feature = "parser")]
    #[test]
    fn parse_time_ms_rejects_empty_blank_unicode_and_garbage() {
        for input in ["", " ", "   ", "\t\n", "\u{1F600}", "e\u{0301}", "٥ms", "500ms"] {
            assert_eq!(parse_time_ms(input), None, "expected {input:?} to be rejected");
        }
    }
    #[cfg(feature = "parser")]
    #[test]
    fn parse_time_ms_rejects_negative_durations() {
        assert_eq!(parse_time_ms("-1ms"), None);
        assert_eq!(parse_time_ms("-0.5s"), None);
        assert_eq!(parse_time_ms("-inf ms"), None);
    }
    /// Negative *zero* is not less than zero in IEEE-754, so it slips past the
    /// `< 0.0` guard and casts to 0 — harmless, but worth pinning.
    #[cfg(feature = "parser")]
    #[test]
    fn parse_time_ms_accepts_negative_zero_as_zero() {
        assert_eq!(parse_time_ms("-0ms"), Some(0));
        assert_eq!(parse_time_ms("-0.0s"), Some(0));
    }
    /// The float -> u32 cast saturates instead of wrapping or panicking:
    /// `inf` clamps to `u32::MAX`, `NaN` becomes 0. Both are *safe* (no UB, no
    /// panic), but note that `"infms"` and `"NaNms"` are accepted as durations
    /// at all — a stricter parser would reject non-finite times outright.
    #[cfg(feature = "parser")]
    #[test]
    fn parse_time_ms_saturates_on_non_finite_and_huge_values() {
        assert_eq!(parse_time_ms("infms"), Some(u32::MAX));
        assert_eq!(parse_time_ms("infinityms"), Some(u32::MAX));
        assert_eq!(parse_time_ms("infs"), Some(u32::MAX));
        assert_eq!(parse_time_ms("nanms"), Some(0));
        assert_eq!(parse_time_ms("NaNms"), Some(0));
        assert_eq!(parse_time_ms("1e30ms"), Some(u32::MAX));
        assert_eq!(parse_time_ms("1e400ms"), Some(u32::MAX), "overflows f32 to inf");
        assert_eq!(parse_time_ms("4294967296ms"), Some(u32::MAX), "2^32 clamps");
        assert_eq!(parse_time_ms("1e-30ms"), Some(0), "underflows to zero");
        // A million digits must saturate, not hang.
        let long_number = format!("{}ms", "9".repeat(100_000));
        assert_eq!(parse_time_ms(&long_number), Some(u32::MAX));
    }
    /// Seconds are multiplied by 1000 *before* the cast, so a value that fits in
    /// a u32 as seconds can still saturate as milliseconds.
    #[cfg(feature = "parser")]
    #[test]
    fn parse_time_ms_saturates_when_seconds_overflow_milliseconds() {
        // Exact while the millisecond product stays inside f32's integer range.
        assert_eq!(parse_time_ms("1000s"), Some(1_000_000));
        assert_eq!(parse_time_ms("16777s"), Some(16_777_000));
        // Past u32::MAX milliseconds the cast clamps instead of wrapping.
        assert_eq!(parse_time_ms("4294968s"), Some(u32::MAX));
        assert_eq!(parse_time_ms("5000000s"), Some(u32::MAX));
        // Just under the clamp, the f32 product is only accurate to ~256ms
        // (the ulp at that magnitude) — near, but no longer exact.
        let ms = parse_time_ms("4294967s").expect("4294967s must parse");
        assert!(
            ms.abs_diff(4_294_967_000) <= 512,
            "4294967s decoded to {ms}, which is nowhere near 4294967000ms"
        );
        assert_eq!(parse_time_ms("0.0005s"), Some(0), "sub-ms truncates toward zero");
    }
    // ======================================================================
    // parse_scrollbar_fade_delay / parse_scrollbar_fade_duration  (parsers)
    // ======================================================================
    #[cfg(feature = "parser")]
    #[test]
    fn fade_parsers_accept_the_documented_syntax() {
        assert_eq!(
            parse_scrollbar_fade_delay("500ms"),
            Ok(ScrollbarFadeDelay::new(500))
        );
        assert_eq!(parse_scrollbar_fade_delay("0"), Ok(ScrollbarFadeDelay::ZERO));
        assert_eq!(
            parse_scrollbar_fade_delay(" 1s "),
            Ok(ScrollbarFadeDelay::new(1000))
        );
        assert_eq!(
            parse_scrollbar_fade_duration("200ms"),
            Ok(ScrollbarFadeDuration::new(200))
        );
        assert_eq!(
            parse_scrollbar_fade_duration("0"),
            Ok(ScrollbarFadeDuration::ZERO)
        );
    }
    #[cfg(feature = "parser")]
    #[test]
    fn fade_parsers_reject_garbage_and_keep_the_raw_input_in_the_error() {
        for input in GARBAGE {
            assert!(
                parse_scrollbar_fade_delay(input).is_err(),
                "delay: expected {input:?} to be rejected"
            );
            assert!(
                parse_scrollbar_fade_duration(input).is_err(),
                "duration: expected {input:?} to be rejected"
            );
        }
        let raw = "  bogus  ";
        assert_eq!(
            parse_scrollbar_fade_delay(raw),
            Err(ScrollbarFadeDelayParseError::InvalidValue(raw))
        );
        assert_eq!(
            parse_scrollbar_fade_duration(raw),
            Err(ScrollbarFadeDurationParseError::InvalidValue(raw))
        );
    }
    #[cfg(feature = "parser")]
    #[test]
    fn fade_parsers_reject_negative_delays() {
        assert!(parse_scrollbar_fade_delay("-1ms").is_err());
        assert!(parse_scrollbar_fade_delay("-500ms").is_err());
        assert!(parse_scrollbar_fade_duration("-0.5s").is_err());
    }
    #[cfg(feature = "parser")]
    #[test]
    fn fade_parsers_saturate_instead_of_overflowing() {
        assert_eq!(
            parse_scrollbar_fade_delay("1e30ms"),
            Ok(ScrollbarFadeDelay::new(u32::MAX))
        );
        assert_eq!(
            parse_scrollbar_fade_duration("99999999999999s"),
            Ok(ScrollbarFadeDuration::new(u32::MAX))
        );
    }
    #[cfg(feature = "parser")]
    #[test]
    fn fade_parsers_survive_huge_and_nested_input() {
        assert!(parse_scrollbar_fade_delay(&"a".repeat(1_000_000)).is_err());
        assert!(parse_scrollbar_fade_duration(&"0ms".repeat(300_000)).is_err());
        assert!(parse_scrollbar_fade_delay(&"(".repeat(10_000)).is_err());
        assert!(parse_scrollbar_fade_duration(&"[".repeat(10_000)).is_err());
    }
    /// encode -> decode is the identity for every millisecond count an `f32` can
    /// represent exactly (`<= 2^24`), including the `0` special case and the
    /// `u32::MAX` extreme (whose f32 rounding lands back on `u32::MAX` after the
    /// saturating cast).
    #[cfg(feature = "parser")]
    #[test]
    fn fade_delay_round_trips_exactly_up_to_two_pow_24() {
        for ms in [
            0u32,
            1,
            8,
            16,
            200,
            500,
            65_535,
            1_000_000,
            TWO_POW_24 - 1,
            TWO_POW_24,
            u32::MAX,
        ] {
            let value = ScrollbarFadeDelay::new(ms);
            let encoded = value.print_as_css_value();
            assert_eq!(
                parse_scrollbar_fade_delay(&encoded),
                Ok(value),
                "{ms}ms encoded as {encoded:?} did not decode back"
            );
            let value = ScrollbarFadeDuration::new(ms);
            let encoded = value.print_as_css_value();
            assert_eq!(
                parse_scrollbar_fade_duration(&encoded),
                Ok(value),
                "{ms}ms encoded as {encoded:?} did not decode back"
            );
        }
    }
    /// Above 2^24 the round-trip is lossy: the value is snapped to the nearest
    /// representable `f32`. Pinned as a precision limit of the shared duration
    /// parser (a delay is never realistically > 4.6 hours, so this is benign).
    #[cfg(feature = "parser")]
    #[test]
    fn fade_delay_round_trip_is_lossy_above_two_pow_24() {
        let value = ScrollbarFadeDelay::new(TWO_POW_24 + 1);
        let decoded = parse_scrollbar_fade_delay(&value.print_as_css_value()).unwrap();
        assert_ne!(decoded, value, "expected precision loss above 2^24");
        assert_eq!(decoded.ms, TWO_POW_24, "must snap down to the nearest f32");
    }
    // ======================================================================
    // Error to_contained / to_shared  (getters)
    // ======================================================================
    /// Strings that stress the owned<->borrowed error conversions: empty, blank,
    /// multibyte, embedded NUL, and a 100k-byte payload.
    fn error_payloads() -> [String; 6] {
        [
            String::new(),
            String::from(" "),
            String::from("thick"),
            String::from("\u{1F600}\u{0301}"),
            String::from("nul\0inside"),
            "x".repeat(100_000),
        ]
    }
    #[test]
    fn layout_scrollbar_width_error_round_trips_through_owned_and_back() {
        for payload in error_payloads() {
            let shared = LayoutScrollbarWidthParseError::InvalidValue(&payload);
            let owned = shared.to_contained();
            assert_eq!(
                owned,
                LayoutScrollbarWidthParseErrorOwned::InvalidValue(payload.clone().into())
            );
            assert_eq!(owned.to_shared(), shared, "owned -> shared lost information");
            assert_eq!(
                owned.to_shared().to_contained(),
                owned,
                "conversion is not idempotent"
            );
        }
    }
    #[test]
    fn visibility_mode_error_round_trips_through_owned_and_back() {
        for payload in error_payloads() {
            let shared = ScrollbarVisibilityModeParseError::InvalidValue(&payload);
            let owned = shared.to_contained();
            assert_eq!(owned.to_shared(), shared);
            assert_eq!(owned.to_shared().to_contained(), owned);
        }
    }
    #[test]
    fn fade_delay_and_duration_errors_round_trip_through_owned_and_back() {
        for payload in error_payloads() {
            let delay = ScrollbarFadeDelayParseError::InvalidValue(&payload);
            let owned_delay = delay.to_contained();
            assert_eq!(owned_delay.to_shared(), delay);
            assert_eq!(owned_delay.to_shared().to_contained(), owned_delay);
            let duration = ScrollbarFadeDurationParseError::InvalidValue(&payload);
            let owned_duration = duration.to_contained();
            assert_eq!(owned_duration.to_shared(), duration);
            assert_eq!(owned_duration.to_shared().to_contained(), owned_duration);
        }
    }
    #[test]
    fn scrollbar_color_invalid_value_error_round_trips_through_owned_and_back() {
        for payload in error_payloads() {
            let shared = StyleScrollbarColorParseError::InvalidValue(&payload);
            let owned = shared.to_contained();
            assert_eq!(
                owned,
                StyleScrollbarColorParseErrorOwned::InvalidValue(payload.clone().into())
            );
            assert_eq!(owned.to_shared(), shared);
            assert_eq!(owned.to_shared().to_contained(), owned);
        }
    }
    /// The nested `Color` variant must delegate to the color error's own
    /// conversion rather than flattening to a string.
    #[cfg(feature = "parser")]
    #[test]
    fn scrollbar_color_nested_color_error_round_trips_through_owned_and_back() {
        let shared = parse_style_scrollbar_color("notacolor blue").unwrap_err();
        assert!(matches!(shared, StyleScrollbarColorParseError::Color(_)));
        let owned = shared.to_contained();
        assert!(matches!(owned, StyleScrollbarColorParseErrorOwned::Color(_)));
        assert_eq!(owned.to_shared(), shared, "nested color error lost information");
        assert_eq!(owned.to_shared().to_contained(), owned);
    }
    /// Error `Display` must always name the offending input, otherwise a CSS
    /// diagnostic is useless. (`Debug` is implemented as `Display` here.)
    #[cfg(feature = "parser")]
    #[test]
    fn error_display_mentions_the_offending_input() {
        let width = parse_layout_scrollbar_width("thick").unwrap_err();
        assert!(format!("{width}").contains("thick"), "{width}");
        assert!(format!("{width:?}").contains("thick"), "{width:?}");
        let color = parse_style_scrollbar_color("red").unwrap_err();
        assert!(format!("{color}").contains("red"), "{color}");
        let vis = parse_scrollbar_visibility_mode("sometimes").unwrap_err();
        assert!(format!("{vis}").contains("sometimes"), "{vis}");
        let delay = parse_scrollbar_fade_delay("soon").unwrap_err();
        assert!(format!("{delay}").contains("soon"), "{delay}");
        let duration = parse_scrollbar_fade_duration("briefly").unwrap_err();
        assert!(format!("{duration}").contains("briefly"), "{duration}");
    }
    /// Displaying an error whose payload is empty or exotic must not panic on a
    /// byte/char boundary.
    #[test]
    fn error_display_does_not_panic_on_exotic_payloads() {
        for payload in error_payloads() {
            let err = LayoutScrollbarWidthParseError::InvalidValue(&payload);
            assert!(!format!("{err}").is_empty());
            let owned = err.to_contained();
            assert!(!format!("{}", owned.to_shared()).is_empty());
        }
    }
}