1
//! Scrollbar geometry computation — single source of truth for the layout solver.
2
//!
3
//! Provides [`ScrollbarRequirements`] (whether scrollbars are needed and how much
4
//! space they reserve) and [`ScrollbarGeometry`] (track, thumb, and button rects).
5
//!
6
//! The main entry point is [`compute_scrollbar_geometry`], whose output is consumed by:
7
//! - Display list painting (`paint_scrollbars`)
8
//! - GPU transform updates (`update_scrollbar_transforms`)
9
//! - Hit-testing (`hit_test_component`)
10
//! - Drag delta conversion (`handle_scrollbar_drag`)
11

            
12
use azul_core::geom::{LogicalPosition, LogicalRect, LogicalSize};
13
use azul_core::dom::ScrollbarOrientation;
14

            
15
/// Information about scrollbar requirements and dimensions
16
// +spec:overflow:55c244 - scrollbar appearance, size, and edge placement are UA-defined
17
#[derive(Copy, Debug, Clone, Default)]
18
#[repr(C)]
19
pub struct ScrollbarRequirements {
20
    pub needs_horizontal: bool,
21
    pub needs_vertical: bool,
22
    /// Layout-reserved width for a vertical scrollbar (0.0 for overlay)
23
    pub scrollbar_width: f32,
24
    /// Layout-reserved height for a horizontal scrollbar (0.0 for overlay)
25
    pub scrollbar_height: f32,
26
    /// Visual rendering width of the scrollbar in CSS pixels (e.g. 8.0 for thin).
27
    /// Non-zero even for overlay scrollbars. Used by GPU state for thumb positioning.
28
    pub visual_width_px: f32,
29
}
30

            
31
impl ScrollbarRequirements {
32
    /// Checks if the presence of scrollbars reduces the available inner size,
33
    /// which would necessitate a reflow of the content.
34
18897
    #[must_use] pub fn needs_reflow(&self) -> bool {
35
18897
        self.scrollbar_width > 0.0 || self.scrollbar_height > 0.0
36
18897
    }
37

            
38
    // +spec:box-model:20c3c8 - scrollbar space reserved between inner border edge and outer padding edge
39
    // +spec:box-model:32cd53 - scrollbar space subtracted from containing block dimensions
40
    // +spec:overflow:30a49c - scrollbar space subtracted from content area
41
    /// Takes a size (representing a content-box) and returns a new size
42
    /// reduced by the dimensions of any active scrollbars.
43
39550
    #[must_use] pub fn shrink_size(&self, size: LogicalSize) -> LogicalSize {
44
39550
        LogicalSize {
45
39550
            width: (size.width - self.scrollbar_width).max(0.0),
46
39550
            height: (size.height - self.scrollbar_height).max(0.0),
47
39550
        }
48
39550
    }
49
}
50

            
51
/// Single source of truth for scrollbar geometry.
52
///
53
/// Computed once by [`compute_scrollbar_geometry`], then used by:
54
/// - Display list painting (`paint_scrollbars`)
55
/// - GPU transform updates (`update_scrollbar_transforms`)
56
/// - Hit-testing (`hit_test_component`)
57
/// - Drag delta conversion (`handle_scrollbar_drag`)
58
#[derive(Debug, Clone, Copy)]
59
pub struct ScrollbarGeometry {
60
    /// Orientation (vertical or horizontal)
61
    pub orientation: ScrollbarOrientation,
62
    /// The full track rect in the container's coordinate space
63
    pub track_rect: LogicalRect,
64
    /// Button size (square: width = height = `scrollbar_width_px`)
65
    pub button_size: f32,
66
    /// Usable track length after subtracting buttons and corner
67
    /// = `track_total` - 2*`button_size`
68
    pub usable_track_length: f32,
69
    /// The thumb length (min-clamped to 2*`width_px`)
70
    pub thumb_length: f32,
71
    /// Thumb size as ratio of viewport / content (0.0–1.0)
72
    pub thumb_size_ratio: f32,
73
    /// Scroll ratio (0.0 at top/left, 1.0 at bottom/right)
74
    pub scroll_ratio: f32,
75
    /// Thumb offset in pixels from the start of the usable track region
76
    pub thumb_offset: f32,
77
    /// Max scroll distance in content pixels
78
    pub max_scroll: f32,
79
    /// CSS-specified scrollbar thickness (width for vertical, height for horizontal)
80
    pub width_px: f32,
81
}
82

            
83
impl Default for ScrollbarGeometry {
84
1
    fn default() -> Self {
85
1
        Self {
86
1
            orientation: ScrollbarOrientation::Vertical,
87
1
            track_rect: LogicalRect::zero(),
88
1
            button_size: 0.0,
89
1
            usable_track_length: 0.0,
90
1
            thumb_length: 0.0,
91
1
            thumb_size_ratio: 0.0,
92
1
            scroll_ratio: 0.0,
93
1
            thumb_offset: 0.0,
94
1
            max_scroll: 0.0,
95
1
            width_px: 0.0,
96
1
        }
97
1
    }
98
}
99

            
100
/// Compute scrollbar geometry for one axis.
101
///
102
/// This is the **single source of truth** for all scrollbar calculations.
103
/// All consumers (display list painting, GPU transforms, hit-testing, drag)
104
/// must use this function to ensure consistent geometry.
105
///
106
/// # Parameters
107
/// - `orientation`: Vertical or horizontal scrollbar
108
/// - `inner_rect`: The padding-box (border-box minus borders) of the scroll container,
109
///   in the container's coordinate space (absolute window coordinates)
110
/// - `content_size`: Total content size (from `get_content_size()` or `virtual_scroll_size`)
111
/// - `scroll_offset`: Current scroll offset (y for vertical, x for horizontal; positive = scrolled).
112
///   A negative value is overscroll past the scroll origin and pins the thumb at the track start.
113
/// - `scrollbar_width_px`: CSS-resolved scrollbar thickness in pixels
114
/// - `has_other_scrollbar`: Whether the perpendicular scrollbar is also visible
115
///   (reduces track length by one `scrollbar_width_px` for the corner)
116
58
#[must_use] pub fn compute_scrollbar_geometry(
117
58
    orientation: ScrollbarOrientation,
118
58
    inner_rect: LogicalRect,
119
58
    content_size: LogicalSize,
120
58
    scroll_offset: f32,
121
58
    scrollbar_width_px: f32,
122
58
    has_other_scrollbar: bool,
123
58
) -> ScrollbarGeometry {
124
    // For macOS-style overlay scrollbars, callers should pass button_size=0.
125
    // For legacy scrollbars with arrow buttons, button_size=scrollbar_width_px.
126
58
    compute_scrollbar_geometry_with_button_size(
127
58
        orientation,
128
58
        inner_rect,
129
58
        content_size,
130
58
        scroll_offset,
131
58
        scrollbar_width_px,
132
58
        has_other_scrollbar,
133
58
        scrollbar_width_px, // default: reserve button space
134
    )
135
58
}
136

            
137
/// Snap a thumb offset to whole pixels for the value that DRIVES THE PAINT.
138
///
139
/// A scrollbar thumb's position reaches the rasteriser as a GPU value, not as
140
/// a display-list rect, and that value is the ONLY channel that can raise
141
/// damage for the bar (the `ScrollBarStyled` items themselves compare equal
142
/// across a scroll). So an unquantised thumb turns every sub-pixel scroll
143
/// frame into a repaint of the whole bar: a high-resolution trackpad delivers
144
/// deltas around 0.2 px, which on a 100 px viewport over 600 px of content
145
/// moves the thumb by ~0.03 px — a move no one can see, on a window whose
146
/// CONTENT is deliberately left alone by the half-device-pixel scroll
147
/// threshold in the frame builder. Damaging the gutter anyway is the same
148
/// class of false per-frame damage that the idle-skip path exists to kill.
149
///
150
/// Quantising the VALUE (rather than teaching the damage diff to ignore small
151
/// deltas) is what keeps paint and damage in agreement: if we skipped the
152
/// damage while still painting an anti-aliased edge 0.03 px lower, the frame
153
/// would keep stale thumb pixels. Rounded, the two frames are byte-identical,
154
/// so "no damage" is the truth and not an approximation. Accumulation falls
155
/// out for free — the offset is a pure function of the scroll offset, so once
156
/// enough scroll has accrued to cross a pixel, the rounded value changes and
157
/// the bar is damaged then.
158
///
159
/// The unit is the LOGICAL pixel, because the display list (and therefore the
160
/// baked `thumb_initial_transform` this must agree with) is DPI-free by
161
/// construction: the rasteriser multiplies by the DPI factor, and no layout
162
/// stage knows it. On a `HiDPI` screen that costs at most one device pixel of
163
/// thumb precision — against a track that has only `usable_track_length`
164
/// distinct positions to begin with.
165
///
166
/// Both producers of the thumb transform — `paint_scrollbars` (which bakes
167
/// the initial value into the display list) and
168
/// `GpuStateManager::update_scrollbar_transforms` (which overwrites it on
169
/// every scroll) — MUST call this, or the two disagree by a fraction of a
170
/// pixel and the disagreement itself becomes a spurious damage event.
171
#[must_use]
172
18512
pub fn quantize_thumb_offset(thumb_offset: f32) -> f32 {
173
18512
    if thumb_offset.is_finite() {
174
18507
        thumb_offset.round()
175
    } else {
176
5
        0.0
177
    }
178
18512
}
179

            
180
/// Like [`compute_scrollbar_geometry`] but allows overriding the button size.
181
/// Pass `button_size = 0.0` for macOS-style overlay scrollbars (no arrow buttons).
182
30675
#[must_use] pub fn compute_scrollbar_geometry_with_button_size(
183
30675
    orientation: ScrollbarOrientation,
184
30675
    inner_rect: LogicalRect,
185
30675
    content_size: LogicalSize,
186
30675
    scroll_offset: f32,
187
30675
    scrollbar_width_px: f32,
188
30675
    has_other_scrollbar: bool,
189
30675
    button_size: f32,
190
30675
) -> ScrollbarGeometry {
191
30675
    let (track_total, viewport_length, content_length, track_rect) = match orientation {
192
        ScrollbarOrientation::Vertical => {
193
24391
            let track_total = if has_other_scrollbar {
194
3208
                inner_rect.size.height - scrollbar_width_px
195
            } else {
196
21183
                inner_rect.size.height
197
            };
198
24391
            let track_rect = LogicalRect {
199
24391
                origin: LogicalPosition::new(
200
24391
                    inner_rect.origin.x + inner_rect.size.width - scrollbar_width_px,
201
24391
                    inner_rect.origin.y,
202
24391
                ),
203
24391
                size: LogicalSize::new(scrollbar_width_px, track_total),
204
24391
            };
205
24391
            (track_total, inner_rect.size.height, content_size.height, track_rect)
206
        }
207
        ScrollbarOrientation::Horizontal => {
208
6284
            let track_total = if has_other_scrollbar {
209
3205
                inner_rect.size.width - scrollbar_width_px
210
            } else {
211
3079
                inner_rect.size.width
212
            };
213
6284
            let track_rect = LogicalRect {
214
6284
                origin: LogicalPosition::new(
215
6284
                    inner_rect.origin.x,
216
6284
                    inner_rect.origin.y + inner_rect.size.height - scrollbar_width_px,
217
6284
                ),
218
6284
                size: LogicalSize::new(track_total, scrollbar_width_px),
219
6284
            };
220
6284
            (track_total, inner_rect.size.width, content_size.width, track_rect)
221
        }
222
    };
223

            
224
30675
    compute_thumb_geometry(
225
30675
        orientation,
226
30675
        track_rect,
227
30675
        track_total,
228
30675
        viewport_length,
229
30675
        content_length,
230
30675
        button_size,
231
30675
        scrollbar_width_px,
232
30675
        scroll_offset,
233
    )
234
30675
}
235

            
236
#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
237
30680
fn compute_thumb_geometry(
238
30680
    orientation: ScrollbarOrientation,
239
30680
    track_rect: LogicalRect,
240
30680
    track_total: f32,
241
30680
    viewport_length: f32,
242
30680
    content_length: f32,
243
30680
    button_size: f32,
244
30680
    scrollbar_width_px: f32,
245
30680
    scroll_offset: f32,
246
30680
) -> ScrollbarGeometry {
247
30680
    let usable_track_length = (track_total - 2.0 * button_size).max(0.0);
248

            
249
30680
    let thumb_size_ratio = if content_length > 0.0 {
250
28272
        (viewport_length / content_length).min(1.0)
251
    } else {
252
2408
        1.0
253
    };
254
30680
    let thumb_length = (usable_track_length * thumb_size_ratio)
255
30680
        .max(scrollbar_width_px * 2.0)
256
30680
        .min(usable_track_length);
257

            
258
30680
    let max_scroll = (content_length - viewport_length).max(0.0);
259
    // A negative offset only ever comes from the rubber-band overscroll of
260
    // `ScrollManager::set_scroll_position_unclamped` (every other writer clamps to
261
    // `[0, max_scroll]`), i.e. the user is pulling *past the scroll origin* — the
262
    // clamp below pins the thumb at the start of the track. Taking the absolute
263
    // value instead would send it down the track while the pull goes up.
264
30680
    let scroll_ratio = if max_scroll > 0.0 {
265
23549
        (scroll_offset / max_scroll).clamp(0.0, 1.0)
266
    } else {
267
7131
        0.0
268
    };
269

            
270
30680
    let thumb_offset = (usable_track_length - thumb_length) * scroll_ratio;
271

            
272
30680
    ScrollbarGeometry {
273
30680
        orientation,
274
30680
        track_rect,
275
30680
        button_size,
276
30680
        usable_track_length,
277
30680
        thumb_length,
278
30680
        thumb_size_ratio,
279
30680
        scroll_ratio,
280
30680
        thumb_offset,
281
30680
        max_scroll,
282
30680
        width_px: scrollbar_width_px,
283
30680
    }
284
30680
}
285

            
286
#[cfg(test)]
287
mod autotest_generated {
288
    use super::*;
289

            
290
    // ---------------------------------------------------------------------
291
    // helpers
292
    // ---------------------------------------------------------------------
293

            
294
    fn rect(x: f32, y: f32, w: f32, h: f32) -> LogicalRect {
295
        LogicalRect::new(LogicalPosition::new(x, y), LogicalSize::new(w, h))
296
    }
297

            
298
    fn reqs(width: f32, height: f32) -> ScrollbarRequirements {
299
        ScrollbarRequirements {
300
            needs_horizontal: height > 0.0,
301
            needs_vertical: width > 0.0,
302
            scrollbar_width: width,
303
            scrollbar_height: height,
304
            visual_width_px: 15.0,
305
        }
306
    }
307

            
308
    #[track_caller]
309
    fn approx(actual: f32, expected: f32) {
310
        assert!(
311
            (actual - expected).abs() <= 1e-3,
312
            "expected {expected}, got {actual}"
313
        );
314
    }
315

            
316
    // ---------------------------------------------------------------------
317
    // quantize_thumb_offset  (paint/damage quantiser)
318
    // ---------------------------------------------------------------------
319

            
320
    #[test]
321
    fn quantize_thumb_offset_rounds_to_the_nearest_whole_pixel() {
322
        approx(quantize_thumb_offset(0.0), 0.0);
323
        approx(quantize_thumb_offset(0.03), 0.0);
324
        approx(quantize_thumb_offset(0.499), 0.0);
325
        approx(quantize_thumb_offset(0.5), 1.0);
326
        approx(quantize_thumb_offset(3.12), 3.0);
327
        approx(quantize_thumb_offset(5.0), 5.0);
328
        approx(quantize_thumb_offset(83.33), 83.0);
329
    }
330

            
331
    #[test]
332
    fn quantize_thumb_offset_is_the_finite_guard_on_the_transform() {
333
        // (inf - inf) * ratio = NaN reaches here from an infinite used_size;
334
        // a NaN in a translation matrix never compares equal to itself, so the
335
        // GPU cache would re-emit a Changed event every frame forever and
336
        // WebRender would be handed a NaN transform.
337
        assert_eq!(quantize_thumb_offset(f32::NAN), 0.0);
338
        assert_eq!(quantize_thumb_offset(f32::INFINITY), 0.0);
339
        assert_eq!(quantize_thumb_offset(f32::NEG_INFINITY), 0.0);
340
    }
341

            
342
    #[test]
343
    fn quantize_thumb_offset_accumulates_rather_than_swallowing_slow_scrolls() {
344
        // The offset is a pure function of the scroll offset, so no separate
345
        // accumulator is needed: sub-pixel steps report the same value until
346
        // enough scroll has accrued to cross a pixel, and then they report the
347
        // next one. (0.2/0.4/0.6 px of scroll on a 100-over-600 viewport ->
348
        // 0.033/0.067/0.1 px of thumb; 6 px of scroll -> 1 px of thumb.)
349
        let thumb_for = |scroll: f32| {
350
            quantize_thumb_offset(
351
                compute_scrollbar_geometry(
352
                    ScrollbarOrientation::Vertical,
353
                    rect(0.0, 0.0, 200.0, 100.0),
354
                    LogicalSize::new(180.0, 600.0),
355
                    scroll,
356
                    8.0,
357
                    false,
358
                )
359
                .thumb_offset,
360
            )
361
        };
362
        assert_eq!(thumb_for(0.0), thumb_for(0.2));
363
        assert_eq!(thumb_for(0.0), thumb_for(0.6));
364
        assert!(
365
            thumb_for(30.0) > thumb_for(0.0),
366
            "a scroll big enough to move the thumb a whole pixel must still move it"
367
        );
368
    }
369

            
370
    // ---------------------------------------------------------------------
371
    // ScrollbarRequirements::needs_reflow  (getter / predicate)
372
    // ---------------------------------------------------------------------
373

            
374
    #[test]
375
    fn needs_reflow_default_instance_is_false() {
376
        assert!(!ScrollbarRequirements::default().needs_reflow());
377
    }
378

            
379
    #[test]
380
    fn needs_reflow_true_when_either_axis_reserves_space() {
381
        assert!(reqs(15.0, 0.0).needs_reflow());
382
        assert!(reqs(0.0, 15.0).needs_reflow());
383
        assert!(reqs(15.0, 15.0).needs_reflow());
384
        assert!(!reqs(0.0, 0.0).needs_reflow());
385
    }
386

            
387
    #[test]
388
    fn needs_reflow_is_false_for_overlay_scrollbars() {
389
        // Overlay scrollbars are visible (visual_width_px > 0) but reserve no
390
        // layout space, so they must never trigger a reflow.
391
        let overlay = ScrollbarRequirements {
392
            needs_horizontal: true,
393
            needs_vertical: true,
394
            scrollbar_width: 0.0,
395
            scrollbar_height: 0.0,
396
            visual_width_px: 12.0,
397
        };
398
        assert!(!overlay.needs_reflow());
399
    }
400

            
401
    #[test]
402
    fn needs_reflow_does_not_panic_on_extreme_values() {
403
        // NaN compares false against every bound, so it reads as "no space reserved".
404
        assert!(!reqs(f32::NAN, f32::NAN).needs_reflow());
405
        // Negative / -inf reservations are nonsense but must not report a reflow.
406
        assert!(!reqs(-1.0, -1.0).needs_reflow());
407
        assert!(!reqs(f32::NEG_INFINITY, f32::NEG_INFINITY).needs_reflow());
408
        assert!(!reqs(f32::MIN, f32::MIN).needs_reflow());
409
        assert!(!reqs(-0.0, -0.0).needs_reflow());
410
        // Anything strictly positive, however small or large, does.
411
        assert!(reqs(f32::MIN_POSITIVE, 0.0).needs_reflow());
412
        assert!(reqs(0.0, f32::MAX).needs_reflow());
413
        assert!(reqs(f32::INFINITY, 0.0).needs_reflow());
414
    }
415

            
416
    // ---------------------------------------------------------------------
417
    // ScrollbarRequirements::shrink_size  (numeric)
418
    // ---------------------------------------------------------------------
419

            
420
    #[test]
421
    fn shrink_size_zero_reservation_is_the_identity() {
422
        let out = reqs(0.0, 0.0).shrink_size(LogicalSize::new(800.0, 600.0));
423
        approx(out.width, 800.0);
424
        approx(out.height, 600.0);
425
    }
426

            
427
    #[test]
428
    fn shrink_size_subtracts_each_axis_independently() {
429
        let out = reqs(15.0, 0.0).shrink_size(LogicalSize::new(100.0, 200.0));
430
        approx(out.width, 85.0);
431
        approx(out.height, 200.0);
432

            
433
        let out = reqs(0.0, 15.0).shrink_size(LogicalSize::new(100.0, 200.0));
434
        approx(out.width, 100.0);
435
        approx(out.height, 185.0);
436
    }
437

            
438
    #[test]
439
    fn shrink_size_clamps_at_zero_and_never_returns_negative() {
440
        let out = reqs(100.0, 100.0).shrink_size(LogicalSize::new(10.0, 10.0));
441
        approx(out.width, 0.0);
442
        approx(out.height, 0.0);
443
        assert!(out.width >= 0.0 && out.height >= 0.0);
444

            
445
        // Zero-sized content box stays at zero.
446
        let out = reqs(15.0, 15.0).shrink_size(LogicalSize::new(0.0, 0.0));
447
        approx(out.width, 0.0);
448
        approx(out.height, 0.0);
449
    }
450

            
451
    #[test]
452
    fn shrink_size_at_float_min_max_does_not_panic() {
453
        // MAX - MAX == 0.0
454
        let out = reqs(f32::MAX, f32::MAX).shrink_size(LogicalSize::new(f32::MAX, f32::MAX));
455
        approx(out.width, 0.0);
456
        approx(out.height, 0.0);
457

            
458
        // MAX with nothing reserved survives unchanged (no overflow).
459
        let out = reqs(0.0, 0.0).shrink_size(LogicalSize::new(f32::MAX, f32::MAX));
460
        assert!(out.width.is_finite() && out.height.is_finite());
461
        approx(out.width / f32::MAX, 1.0);
462

            
463
        // A negative (MIN) input size is clamped up to zero.
464
        let out = reqs(0.0, 0.0).shrink_size(LogicalSize::new(f32::MIN, f32::MIN));
465
        approx(out.width, 0.0);
466
        approx(out.height, 0.0);
467
    }
468

            
469
    #[test]
470
    fn shrink_size_with_nan_or_infinite_inputs_is_defined() {
471
        // NaN propagates into the subtraction, but f32::max(NaN, 0.0) == 0.0,
472
        // so the result is a defined (zero) size rather than a NaN size.
473
        let out = reqs(f32::NAN, f32::NAN).shrink_size(LogicalSize::new(100.0, 100.0));
474
        approx(out.width, 0.0);
475
        approx(out.height, 0.0);
476

            
477
        let out = reqs(0.0, 0.0).shrink_size(LogicalSize::new(f32::NAN, f32::NAN));
478
        approx(out.width, 0.0);
479
        approx(out.height, 0.0);
480

            
481
        // inf - inf == NaN -> clamped to 0.0
482
        let out =
483
            reqs(f32::INFINITY, f32::INFINITY).shrink_size(LogicalSize::new(f32::INFINITY, f32::INFINITY));
484
        approx(out.width, 0.0);
485
        approx(out.height, 0.0);
486

            
487
        // An infinite content box with a finite reservation stays infinite.
488
        let out = reqs(15.0, 15.0).shrink_size(LogicalSize::new(f32::INFINITY, f32::INFINITY));
489
        assert!(out.width.is_infinite() && out.width.is_sign_positive());
490
        assert!(out.height.is_infinite() && out.height.is_sign_positive());
491
    }
492

            
493
    #[test]
494
    fn shrink_size_with_negative_reservation_grows_the_box() {
495
        // Characterization: shrink_size does not clamp the *reservation*, only the
496
        // result. A negative reserved width therefore enlarges the content box.
497
        // Negative reservations are not producible by the CSS resolver today; this
498
        // pins the behaviour so a future clamp is a deliberate, visible change.
499
        let out = reqs(-10.0, -10.0).shrink_size(LogicalSize::new(100.0, 100.0));
500
        approx(out.width, 110.0);
501
        approx(out.height, 110.0);
502
    }
503

            
504
    #[test]
505
    fn shrink_size_is_identity_exactly_when_no_reflow_is_needed() {
506
        // Property: for the non-negative reservations the solver can actually
507
        // produce, !needs_reflow() <=> shrink_size() leaves the size untouched.
508
        for w in [0.0_f32, 1.0, 8.0, 15.0, 100.0] {
509
            for h in [0.0_f32, 1.0, 8.0, 15.0, 100.0] {
510
                let r = reqs(w, h);
511
                let size = LogicalSize::new(500.0, 500.0);
512
                let out = r.shrink_size(size);
513
                let unchanged = (out.width - size.width).abs() < f32::EPSILON
514
                    && (out.height - size.height).abs() < f32::EPSILON;
515
                assert_eq!(!r.needs_reflow(), unchanged, "w={w} h={h}");
516
            }
517
        }
518
    }
519

            
520
    // ---------------------------------------------------------------------
521
    // compute_scrollbar_geometry  (numeric)
522
    // ---------------------------------------------------------------------
523

            
524
    #[test]
525
    fn vertical_geometry_places_the_track_on_the_right_inner_edge() {
526
        let g = compute_scrollbar_geometry(
527
            ScrollbarOrientation::Vertical,
528
            rect(10.0, 20.0, 100.0, 200.0),
529
            LogicalSize::new(100.0, 400.0),
530
            0.0,
531
            15.0,
532
            false,
533
        );
534
        assert_eq!(g.orientation, ScrollbarOrientation::Vertical);
535
        approx(g.track_rect.origin.x, 10.0 + 100.0 - 15.0);
536
        approx(g.track_rect.origin.y, 20.0);
537
        approx(g.track_rect.size.width, 15.0);
538
        approx(g.track_rect.size.height, 200.0);
539
        approx(g.button_size, 15.0);
540
        approx(g.usable_track_length, 200.0 - 2.0 * 15.0);
541
        approx(g.thumb_size_ratio, 0.5);
542
        approx(g.thumb_length, 85.0);
543
        approx(g.max_scroll, 200.0);
544
        approx(g.scroll_ratio, 0.0);
545
        approx(g.thumb_offset, 0.0);
546
        approx(g.width_px, 15.0);
547
    }
548

            
549
    #[test]
550
    fn horizontal_geometry_places_the_track_on_the_bottom_inner_edge() {
551
        let g = compute_scrollbar_geometry(
552
            ScrollbarOrientation::Horizontal,
553
            rect(10.0, 20.0, 100.0, 200.0),
554
            LogicalSize::new(300.0, 200.0),
555
            100.0,
556
            15.0,
557
            false,
558
        );
559
        assert_eq!(g.orientation, ScrollbarOrientation::Horizontal);
560
        approx(g.track_rect.origin.x, 10.0);
561
        approx(g.track_rect.origin.y, 20.0 + 200.0 - 15.0);
562
        approx(g.track_rect.size.width, 100.0);
563
        approx(g.track_rect.size.height, 15.0);
564
        approx(g.usable_track_length, 70.0);
565
        approx(g.thumb_size_ratio, 1.0 / 3.0);
566
        // 70 * 0.333 = 23.3 -> lifted to the 2*width minimum (30)
567
        approx(g.thumb_length, 30.0);
568
        approx(g.max_scroll, 200.0);
569
        approx(g.scroll_ratio, 0.5);
570
        approx(g.thumb_offset, (70.0 - 30.0) * 0.5);
571
    }
572

            
573
    #[test]
574
    fn the_other_scrollbar_steals_exactly_one_width_from_the_track() {
575
        let without = compute_scrollbar_geometry(
576
            ScrollbarOrientation::Vertical,
577
            rect(0.0, 0.0, 100.0, 200.0),
578
            LogicalSize::new(100.0, 400.0),
579
            0.0,
580
            15.0,
581
            false,
582
        );
583
        let with = compute_scrollbar_geometry(
584
            ScrollbarOrientation::Vertical,
585
            rect(0.0, 0.0, 100.0, 200.0),
586
            LogicalSize::new(100.0, 400.0),
587
            0.0,
588
            15.0,
589
            true,
590
        );
591
        approx(without.track_rect.size.height - with.track_rect.size.height, 15.0);
592
        approx(without.usable_track_length - with.usable_track_length, 15.0);
593
        approx(with.usable_track_length, 200.0 - 15.0 - 30.0);
594
    }
595

            
596
    #[test]
597
    fn compute_scrollbar_geometry_defaults_to_button_size_equal_to_width() {
598
        for orientation in [ScrollbarOrientation::Vertical, ScrollbarOrientation::Horizontal] {
599
            let a = compute_scrollbar_geometry(
600
                orientation,
601
                rect(5.0, 7.0, 120.0, 240.0),
602
                LogicalSize::new(500.0, 900.0),
603
                33.0,
604
                17.0,
605
                true,
606
            );
607
            let b = compute_scrollbar_geometry_with_button_size(
608
                orientation,
609
                rect(5.0, 7.0, 120.0, 240.0),
610
                LogicalSize::new(500.0, 900.0),
611
                33.0,
612
                17.0,
613
                true,
614
                17.0,
615
            );
616
            approx(a.button_size, b.button_size);
617
            approx(a.usable_track_length, b.usable_track_length);
618
            approx(a.thumb_length, b.thumb_length);
619
            approx(a.thumb_offset, b.thumb_offset);
620
            approx(a.max_scroll, b.max_scroll);
621
        }
622
    }
623

            
624
    #[test]
625
    fn everything_zero_yields_an_all_zero_geometry() {
626
        let g = compute_scrollbar_geometry(
627
            ScrollbarOrientation::Vertical,
628
            LogicalRect::zero(),
629
            LogicalSize::new(0.0, 0.0),
630
            0.0,
631
            0.0,
632
            false,
633
        );
634
        approx(g.usable_track_length, 0.0);
635
        approx(g.thumb_length, 0.0);
636
        approx(g.thumb_offset, 0.0);
637
        approx(g.max_scroll, 0.0);
638
        approx(g.scroll_ratio, 0.0);
639
        // Zero content means "everything fits" -> the thumb covers the whole track.
640
        approx(g.thumb_size_ratio, 1.0);
641
    }
642

            
643
    #[test]
644
    fn content_smaller_than_viewport_is_not_scrollable() {
645
        let g = compute_scrollbar_geometry(
646
            ScrollbarOrientation::Vertical,
647
            rect(0.0, 0.0, 100.0, 400.0),
648
            LogicalSize::new(100.0, 50.0),
649
            9999.0, // bogus scroll offset on a non-scrollable box
650
            15.0,
651
            false,
652
        );
653
        approx(g.thumb_size_ratio, 1.0);
654
        approx(g.max_scroll, 0.0);
655
        approx(g.scroll_ratio, 0.0);
656
        approx(g.thumb_offset, 0.0);
657
        approx(g.thumb_length, g.usable_track_length);
658
    }
659

            
660
    #[test]
661
    fn overscroll_clamps_the_thumb_to_the_end_of_the_track() {
662
        let g = compute_scrollbar_geometry(
663
            ScrollbarOrientation::Vertical,
664
            rect(0.0, 0.0, 100.0, 200.0),
665
            LogicalSize::new(100.0, 400.0),
666
            1.0e9, // far past max_scroll (200)
667
            15.0,
668
            false,
669
        );
670
        approx(g.scroll_ratio, 1.0);
671
        approx(g.thumb_offset, g.usable_track_length - g.thumb_length);
672
        assert!(g.thumb_offset + g.thumb_length <= g.usable_track_length + 1e-3);
673
    }
674

            
675
    #[test]
676
    fn a_negative_overscroll_offset_pins_the_thumb_to_the_start_of_the_track() {
677
        // The only writer that can produce a negative offset is the physics timer's
678
        // `set_scroll_position_unclamped` rubber-band, i.e. the user pulling past the
679
        // top. The thumb must stay parked at the track start, NOT mirror the pull
680
        // downwards the way an `.abs()` on the ratio used to make it.
681
        //   usable = 200 - 2*15 = 170, thumb = max(170 * 200/400, 30) = 85,
682
        //   max_scroll = 200 -> travel = 85.
683
        let neg = compute_scrollbar_geometry(
684
            ScrollbarOrientation::Vertical,
685
            rect(0.0, 0.0, 100.0, 200.0),
686
            LogicalSize::new(100.0, 400.0),
687
            -50.0,
688
            15.0,
689
            false,
690
        );
691
        approx(neg.scroll_ratio, 0.0);
692
        approx(neg.thumb_offset, 0.0);
693

            
694
        // ...and the mirrored positive offset is a genuinely different position.
695
        let pos = compute_scrollbar_geometry(
696
            ScrollbarOrientation::Vertical,
697
            rect(0.0, 0.0, 100.0, 200.0),
698
            LogicalSize::new(100.0, 400.0),
699
            50.0,
700
            15.0,
701
            false,
702
        );
703
        approx(pos.scroll_ratio, 0.25);
704
        approx(pos.thumb_offset, 21.25);
705
        assert!(pos.thumb_offset > neg.thumb_offset);
706
    }
707

            
708
    #[test]
709
    fn very_long_content_lifts_the_thumb_to_the_minimum_length() {
710
        let g = compute_scrollbar_geometry(
711
            ScrollbarOrientation::Vertical,
712
            rect(0.0, 0.0, 100.0, 200.0),
713
            LogicalSize::new(100.0, 1.0e6),
714
            0.0,
715
            15.0,
716
            false,
717
        );
718
        // proportional thumb would be ~0.03px; the 2*width floor kicks in
719
        approx(g.thumb_length, 30.0);
720
        assert!(g.thumb_length <= g.usable_track_length);
721
    }
722

            
723
    #[test]
724
    fn the_minimum_thumb_length_is_capped_by_the_usable_track() {
725
        // Track (40px) is barely bigger than the two buttons (2*15) -> 10px usable.
726
        // The 30px minimum thumb must be clamped down, never overflow the track.
727
        let g = compute_scrollbar_geometry(
728
            ScrollbarOrientation::Vertical,
729
            rect(0.0, 0.0, 100.0, 40.0),
730
            LogicalSize::new(100.0, 1000.0),
731
            500.0,
732
            15.0,
733
            false,
734
        );
735
        approx(g.usable_track_length, 10.0);
736
        approx(g.thumb_length, 10.0);
737
        approx(g.thumb_offset, 0.0);
738
        assert!(g.thumb_offset + g.thumb_length <= g.usable_track_length + 1e-3);
739
    }
740

            
741
    #[test]
742
    fn a_container_thinner_than_its_scrollbar_still_produces_safe_lengths() {
743
        // 10px tall container, 15px scrollbar, corner reserved => track_total = -5.
744
        // Characterization: the *track rect* is allowed to go negative here (it is
745
        // clipped by the painter), but every derived length stays non-negative.
746
        let g = compute_scrollbar_geometry(
747
            ScrollbarOrientation::Vertical,
748
            rect(0.0, 0.0, 100.0, 10.0),
749
            LogicalSize::new(100.0, 400.0),
750
            10.0,
751
            15.0,
752
            true,
753
        );
754
        assert!(g.track_rect.size.height < 0.0, "track rect goes negative");
755
        approx(g.usable_track_length, 0.0);
756
        approx(g.thumb_length, 0.0);
757
        approx(g.thumb_offset, 0.0);
758
    }
759

            
760
    #[test]
761
    fn zero_button_size_gives_the_whole_track_to_the_thumb() {
762
        let g = compute_scrollbar_geometry_with_button_size(
763
            ScrollbarOrientation::Vertical,
764
            rect(0.0, 0.0, 100.0, 200.0),
765
            LogicalSize::new(100.0, 400.0),
766
            0.0,
767
            8.0,
768
            false,
769
            0.0, // overlay scrollbar: no arrow buttons
770
        );
771
        approx(g.button_size, 0.0);
772
        approx(g.usable_track_length, 200.0);
773
        approx(g.thumb_length, 100.0);
774
        approx(g.width_px, 8.0);
775
    }
776

            
777
    #[test]
778
    fn zero_width_scrollbar_does_not_panic() {
779
        let g = compute_scrollbar_geometry(
780
            ScrollbarOrientation::Horizontal,
781
            rect(0.0, 0.0, 100.0, 200.0),
782
            LogicalSize::new(400.0, 200.0),
783
            100.0,
784
            0.0,
785
            false,
786
        );
787
        approx(g.usable_track_length, 100.0);
788
        approx(g.width_px, 0.0);
789
        approx(g.thumb_size_ratio, 0.25);
790
        approx(g.thumb_length, 25.0);
791
        assert!(g.thumb_offset >= 0.0);
792
    }
793

            
794
    #[test]
795
    fn negative_content_size_is_treated_as_unscrollable() {
796
        let g = compute_scrollbar_geometry(
797
            ScrollbarOrientation::Vertical,
798
            rect(0.0, 0.0, 100.0, 200.0),
799
            LogicalSize::new(100.0, -400.0),
800
            50.0,
801
            15.0,
802
            false,
803
        );
804
        // `content_length > 0.0` is false -> ratio 1.0, no scrolling.
805
        approx(g.thumb_size_ratio, 1.0);
806
        approx(g.max_scroll, 0.0);
807
        approx(g.scroll_ratio, 0.0);
808
        approx(g.thumb_offset, 0.0);
809
        approx(g.thumb_length, g.usable_track_length);
810
    }
811

            
812
    #[test]
813
    fn a_negative_viewport_still_produces_non_negative_lengths() {
814
        // Degenerate inner rect (negative height). The size *ratio* goes negative,
815
        // but the lengths that reach the painter must not.
816
        let g = compute_scrollbar_geometry(
817
            ScrollbarOrientation::Vertical,
818
            rect(0.0, 0.0, 100.0, -100.0),
819
            LogicalSize::new(100.0, 200.0),
820
            50.0,
821
            15.0,
822
            false,
823
        );
824
        approx(g.thumb_size_ratio, -0.5);
825
        approx(g.usable_track_length, 0.0);
826
        approx(g.thumb_length, 0.0);
827
        approx(g.thumb_offset, 0.0);
828
        assert!(g.max_scroll >= 0.0);
829
    }
830

            
831
    #[test]
832
    fn float_max_inputs_stay_finite() {
833
        let g = compute_scrollbar_geometry(
834
            ScrollbarOrientation::Vertical,
835
            rect(0.0, 0.0, f32::MAX, f32::MAX),
836
            LogicalSize::new(f32::MAX, f32::MAX),
837
            f32::MAX,
838
            15.0,
839
            true,
840
        );
841
        assert!(g.usable_track_length.is_finite());
842
        assert!(g.thumb_length.is_finite());
843
        assert!(g.thumb_offset.is_finite());
844
        assert!(g.max_scroll.is_finite());
845
        assert!(g.scroll_ratio.is_finite());
846
        assert!(g.thumb_size_ratio.is_finite());
847
        approx(g.thumb_size_ratio, 1.0);
848
        approx(g.max_scroll, 0.0);
849
        approx(g.thumb_offset, 0.0);
850
    }
851

            
852
    #[test]
853
    fn infinite_content_length_does_not_panic() {
854
        let g = compute_scrollbar_geometry(
855
            ScrollbarOrientation::Vertical,
856
            rect(0.0, 0.0, 100.0, 200.0),
857
            LogicalSize::new(100.0, f32::INFINITY),
858
            100.0,
859
            15.0,
860
            false,
861
        );
862
        approx(g.thumb_size_ratio, 0.0);
863
        approx(g.thumb_length, 30.0); // floored at 2 * width
864
        assert!(g.max_scroll.is_infinite());
865
        // finite_offset / inf == 0 -> the thumb parks at the start
866
        approx(g.scroll_ratio, 0.0);
867
        approx(g.thumb_offset, 0.0);
868
    }
869

            
870
    #[test]
871
    fn nan_scrollbar_width_does_not_panic_and_collapses_the_track() {
872
        let g = compute_scrollbar_geometry(
873
            ScrollbarOrientation::Vertical,
874
            rect(0.0, 0.0, 100.0, 200.0),
875
            LogicalSize::new(100.0, 400.0),
876
            0.0,
877
            f32::NAN,
878
            false,
879
        );
880
        // NaN width => NaN button size => the usable track clamps to 0 and the
881
        // thumb collapses. Nothing paints, but nothing panics either.
882
        approx(g.usable_track_length, 0.0);
883
        approx(g.thumb_length, 0.0);
884
        approx(g.thumb_offset, 0.0);
885
        assert!(g.width_px.is_nan());
886
        assert!(g.track_rect.origin.x.is_nan());
887
    }
888

            
889
    #[test]
890
    fn nan_content_size_does_not_panic() {
891
        let g = compute_scrollbar_geometry(
892
            ScrollbarOrientation::Vertical,
893
            rect(0.0, 0.0, 100.0, 200.0),
894
            LogicalSize::new(100.0, f32::NAN),
895
            50.0,
896
            15.0,
897
            false,
898
        );
899
        // `NaN > 0.0` is false -> ratio 1.0; `NaN - v` is NaN -> max_scroll 0.
900
        approx(g.thumb_size_ratio, 1.0);
901
        approx(g.max_scroll, 0.0);
902
        approx(g.scroll_ratio, 0.0);
903
        approx(g.thumb_offset, 0.0);
904
        approx(g.thumb_length, g.usable_track_length);
905
        assert!(g.thumb_length.is_finite());
906
    }
907

            
908
    #[test]
909
    fn infinite_scroll_offsets_saturate_the_scroll_ratio() {
910
        // +INF saturates at the END of the track; -INF is an overscroll past
911
        // the start and PINS AT 0 (the `.abs()` that used to mirror it down
912
        // the track was removed with the origin-convention fix — see
913
        // `a_negative_overscroll_offset_pins_the_thumb_to_the_start_of_the_track`).
914
        for (offset, want_ratio) in [(f32::INFINITY, 1.0), (f32::NEG_INFINITY, 0.0)] {
915
            let g = compute_scrollbar_geometry(
916
                ScrollbarOrientation::Vertical,
917
                rect(0.0, 0.0, 100.0, 200.0),
918
                LogicalSize::new(100.0, 400.0),
919
                offset,
920
                15.0,
921
                false,
922
            );
923
            approx(g.scroll_ratio, want_ratio);
924
            approx(
925
                g.thumb_offset,
926
                (g.usable_track_length - g.thumb_length) * want_ratio,
927
            );
928
        }
929
    }
930

            
931
    #[test]
932
    fn nan_scroll_offset_leaks_nan_into_scroll_ratio_and_thumb_offset() {
933
        // FINDING (characterization, not a panic): `f32::clamp` returns NaN for a
934
        // NaN input, so a NaN scroll offset survives into `scroll_ratio` and then
935
        // `thumb_offset`. Every other field stays well-defined. A NaN offset would
936
        // paint the thumb at an undefined position rather than clamping to 0.
937
        let g = compute_scrollbar_geometry(
938
            ScrollbarOrientation::Vertical,
939
            rect(0.0, 0.0, 100.0, 200.0),
940
            LogicalSize::new(100.0, 400.0),
941
            f32::NAN,
942
            15.0,
943
            false,
944
        );
945
        assert!(g.scroll_ratio.is_nan());
946
        assert!(g.thumb_offset.is_nan());
947
        approx(g.usable_track_length, 170.0);
948
        approx(g.thumb_length, 85.0);
949
        approx(g.max_scroll, 200.0);
950
    }
951

            
952
    #[test]
953
    fn infinite_viewport_leaks_nan_into_thumb_offset() {
954
        // FINDING (characterization, not a panic): an infinite inner rect makes both
955
        // `usable_track_length` and `thumb_length` infinite, so `usable - thumb` is
956
        // NaN and the offset follows. No panic; the value is simply undefined.
957
        let g = compute_scrollbar_geometry(
958
            ScrollbarOrientation::Vertical,
959
            rect(0.0, 0.0, 100.0, f32::INFINITY),
960
            LogicalSize::new(100.0, 1000.0),
961
            0.0,
962
            15.0,
963
            false,
964
        );
965
        assert!(g.usable_track_length.is_infinite());
966
        assert!(g.thumb_length.is_infinite());
967
        assert!(g.thumb_offset.is_nan());
968
        approx(g.max_scroll, 0.0);
969
        approx(g.scroll_ratio, 0.0);
970
    }
971

            
972
    // ---------------------------------------------------------------------
973
    // compute_thumb_geometry  (private, numeric)
974
    // ---------------------------------------------------------------------
975

            
976
    #[test]
977
    fn thumb_geometry_math_is_exact_for_a_known_case() {
978
        let track = rect(1.0, 2.0, 3.0, 4.0);
979
        let g = compute_thumb_geometry(
980
            ScrollbarOrientation::Horizontal,
981
            track,
982
            200.0, // track_total
983
            100.0, // viewport_length
984
            200.0, // content_length
985
            10.0,  // button_size
986
            10.0,  // scrollbar_width_px
987
            50.0,  // scroll_offset
988
        );
989
        approx(g.usable_track_length, 180.0); // 200 - 2*10
990
        approx(g.thumb_size_ratio, 0.5); // 100 / 200
991
        approx(g.thumb_length, 90.0); // 180 * 0.5
992
        approx(g.max_scroll, 100.0); // 200 - 100
993
        approx(g.scroll_ratio, 0.5); // 50 / 100
994
        approx(g.thumb_offset, 45.0); // (180 - 90) * 0.5
995
        // the track rect is passed straight through, never recomputed
996
        approx(g.track_rect.origin.x, track.origin.x);
997
        approx(g.track_rect.size.height, track.size.height);
998
        assert_eq!(g.orientation, ScrollbarOrientation::Horizontal);
999
    }
    #[test]
    fn thumb_geometry_buttons_larger_than_the_track_collapse_the_usable_length() {
        let g = compute_thumb_geometry(
            ScrollbarOrientation::Vertical,
            LogicalRect::zero(),
            20.0,   // track_total
            100.0,  // viewport_length
            1000.0, // content_length
            500.0,  // button_size >> track
            15.0,
            250.0,
        );
        approx(g.usable_track_length, 0.0);
        approx(g.thumb_length, 0.0);
        approx(g.thumb_offset, 0.0);
        assert!(g.scroll_ratio >= 0.0 && g.scroll_ratio <= 1.0);
    }
    #[test]
    fn thumb_geometry_ignores_scroll_offset_when_content_fits() {
        let g = compute_thumb_geometry(
            ScrollbarOrientation::Vertical,
            LogicalRect::zero(),
            200.0,
            200.0, // viewport == content
            200.0,
            10.0,
            10.0,
            1.0e9, // absurd offset
        );
        approx(g.max_scroll, 0.0);
        approx(g.scroll_ratio, 0.0);
        approx(g.thumb_offset, 0.0);
        approx(g.thumb_size_ratio, 1.0);
        approx(g.thumb_length, g.usable_track_length);
    }
    #[test]
    fn thumb_geometry_clamps_an_oversized_viewport_ratio_to_one() {
        let g = compute_thumb_geometry(
            ScrollbarOrientation::Vertical,
            LogicalRect::zero(),
            200.0,
            400.0, // viewport bigger than content
            100.0,
            10.0,
            10.0,
            0.0,
        );
        approx(g.thumb_size_ratio, 1.0);
        approx(g.thumb_length, g.usable_track_length);
        approx(g.max_scroll, 0.0);
    }
    #[test]
    fn thumb_geometry_survives_an_all_nan_call() {
        let g = compute_thumb_geometry(
            ScrollbarOrientation::Vertical,
            LogicalRect::zero(),
            f32::NAN,
            f32::NAN,
            f32::NAN,
            f32::NAN,
            f32::NAN,
            f32::NAN,
        );
        // max()/min() drop NaN in favour of the other operand, and the
        // `content_length > 0.0` / `max_scroll > 0.0` guards both read false.
        approx(g.usable_track_length, 0.0);
        approx(g.thumb_length, 0.0);
        approx(g.thumb_size_ratio, 1.0);
        approx(g.max_scroll, 0.0);
        approx(g.scroll_ratio, 0.0);
        approx(g.thumb_offset, 0.0);
    }
    // ---------------------------------------------------------------------
    // round-trip + invariants
    // ---------------------------------------------------------------------
    #[test]
    fn thumb_offset_round_trips_back_to_the_scroll_offset() {
        // This is the inverse used by `handle_scrollbar_drag`: dragging the thumb to
        // `thumb_offset` must map back to the scroll offset that produced it.
        for &offset in &[0.0_f32, 25.0, 50.0, 100.0, 150.0, 200.0] {
            let g = compute_scrollbar_geometry(
                ScrollbarOrientation::Vertical,
                rect(0.0, 0.0, 100.0, 200.0),
                LogicalSize::new(100.0, 400.0),
                offset,
                15.0,
                false,
            );
            let drag_range = g.usable_track_length - g.thumb_length;
            assert!(drag_range > 0.0);
            let recovered = (g.thumb_offset / drag_range) * g.max_scroll;
            approx(recovered, offset);
        }
    }
    #[test]
    fn thumb_offset_is_monotonic_in_the_scroll_offset() {
        let mut previous = f32::NEG_INFINITY;
        for step in 0..=20 {
            let offset = step as f32 * 15.0; // 0 .. 300, past max_scroll (200)
            let g = compute_scrollbar_geometry(
                ScrollbarOrientation::Horizontal,
                rect(0.0, 0.0, 200.0, 100.0),
                LogicalSize::new(400.0, 100.0),
                offset,
                15.0,
                false,
            );
            assert!(
                g.thumb_offset >= previous - 1e-4,
                "thumb went backwards at offset {offset}: {} < {previous}",
                g.thumb_offset
            );
            previous = g.thumb_offset;
        }
    }
    #[test]
    fn geometry_invariants_hold_across_a_finite_input_grid() {
        let orientations = [ScrollbarOrientation::Vertical, ScrollbarOrientation::Horizontal];
        let rects = [
            rect(0.0, 0.0, 0.0, 0.0),
            rect(0.0, 0.0, 1.0, 1.0),
            rect(-50.0, -50.0, 100.0, 200.0),
            rect(10.0, 20.0, 800.0, 600.0),
            rect(0.0, 0.0, f32::MAX, f32::MAX),
        ];
        let contents = [
            LogicalSize::new(0.0, 0.0),
            LogicalSize::new(1.0, 1.0),
            LogicalSize::new(100.0, 200.0),
            LogicalSize::new(1.0e9, 1.0e9),
            LogicalSize::new(f32::MAX, f32::MAX),
        ];
        let offsets = [0.0_f32, -1.0, 1.0, 12_345.0, -12_345.0, f32::MAX];
        let widths = [0.0_f32, 1.0, 8.0, 15.0, 1000.0];
        let buttons = [0.0_f32, 1.0, 15.0, 1000.0];
        for &orientation in &orientations {
            for &inner in &rects {
                for &content in &contents {
                    for &offset in &offsets {
                        for &width in &widths {
                            for &button in &buttons {
                                for &other in &[false, true] {
                                    let g = compute_scrollbar_geometry_with_button_size(
                                        orientation,
                                        inner,
                                        content,
                                        offset,
                                        width,
                                        other,
                                        button,
                                    );
                                    let ctx = format!(
                                        "inner={inner:?} content={content:?} offset={offset} \
                                         width={width} button={button} other={other}"
                                    );
                                    assert!(g.usable_track_length.is_finite(), "{ctx}");
                                    assert!(g.thumb_length.is_finite(), "{ctx}");
                                    assert!(g.thumb_offset.is_finite(), "{ctx}");
                                    assert!(g.max_scroll.is_finite(), "{ctx}");
                                    assert!(g.usable_track_length >= 0.0, "{ctx}");
                                    assert!(g.max_scroll >= 0.0, "{ctx}");
                                    assert!(g.thumb_offset >= 0.0, "{ctx}");
                                    assert!(
                                        g.thumb_length >= 0.0
                                            && g.thumb_length <= g.usable_track_length,
                                        "thumb escapes the track: {ctx}"
                                    );
                                    assert!(
                                        (0.0..=1.0).contains(&g.thumb_size_ratio),
                                        "size ratio out of range: {ctx}"
                                    );
                                    assert!(
                                        (0.0..=1.0).contains(&g.scroll_ratio),
                                        "scroll ratio out of range: {ctx}"
                                    );
                                    let slack = g.usable_track_length
                                        + g.usable_track_length.abs() * 1e-5
                                        + 1e-3;
                                    assert!(
                                        g.thumb_offset + g.thumb_length <= slack,
                                        "thumb overruns the track end: {ctx}"
                                    );
                                }
                            }
                        }
                    }
                }
            }
        }
    }
    #[test]
    fn default_geometry_is_inert() {
        let g = ScrollbarGeometry::default();
        assert_eq!(g.orientation, ScrollbarOrientation::Vertical);
        approx(g.button_size, 0.0);
        approx(g.usable_track_length, 0.0);
        approx(g.thumb_length, 0.0);
        approx(g.thumb_size_ratio, 0.0);
        approx(g.scroll_ratio, 0.0);
        approx(g.thumb_offset, 0.0);
        approx(g.max_scroll, 0.0);
        approx(g.width_px, 0.0);
        assert_eq!(g.track_rect, LogicalRect::zero());
    }
}