Skip to main content

azul_layout/solver3/
scrollbar.rs

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
12use azul_core::geom::{LogicalPosition, LogicalRect, LogicalSize};
13use 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)]
19pub 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
31impl ScrollbarRequirements {
32    /// Checks if the presence of scrollbars reduces the available inner size,
33    /// which would necessitate a reflow of the content.
34    #[must_use] pub fn needs_reflow(&self) -> bool {
35        self.scrollbar_width > 0.0 || self.scrollbar_height > 0.0
36    }
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    #[must_use] pub fn shrink_size(&self, size: LogicalSize) -> LogicalSize {
44        LogicalSize {
45            width: (size.width - self.scrollbar_width).max(0.0),
46            height: (size.height - self.scrollbar_height).max(0.0),
47        }
48    }
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)]
59pub 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
83impl Default for ScrollbarGeometry {
84    fn default() -> Self {
85        Self {
86            orientation: ScrollbarOrientation::Vertical,
87            track_rect: LogicalRect::zero(),
88            button_size: 0.0,
89            usable_track_length: 0.0,
90            thumb_length: 0.0,
91            thumb_size_ratio: 0.0,
92            scroll_ratio: 0.0,
93            thumb_offset: 0.0,
94            max_scroll: 0.0,
95            width_px: 0.0,
96        }
97    }
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/// - `scrollbar_width_px`: CSS-resolved scrollbar thickness in pixels
113/// - `has_other_scrollbar`: Whether the perpendicular scrollbar is also visible
114///   (reduces track length by one `scrollbar_width_px` for the corner)
115#[must_use] pub fn compute_scrollbar_geometry(
116    orientation: ScrollbarOrientation,
117    inner_rect: LogicalRect,
118    content_size: LogicalSize,
119    scroll_offset: f32,
120    scrollbar_width_px: f32,
121    has_other_scrollbar: bool,
122) -> ScrollbarGeometry {
123    // For macOS-style overlay scrollbars, callers should pass button_size=0.
124    // For legacy scrollbars with arrow buttons, button_size=scrollbar_width_px.
125    compute_scrollbar_geometry_with_button_size(
126        orientation,
127        inner_rect,
128        content_size,
129        scroll_offset,
130        scrollbar_width_px,
131        has_other_scrollbar,
132        scrollbar_width_px, // default: reserve button space
133    )
134}
135
136/// Like [`compute_scrollbar_geometry`] but allows overriding the button size.
137/// Pass `button_size = 0.0` for macOS-style overlay scrollbars (no arrow buttons).
138#[must_use] pub fn compute_scrollbar_geometry_with_button_size(
139    orientation: ScrollbarOrientation,
140    inner_rect: LogicalRect,
141    content_size: LogicalSize,
142    scroll_offset: f32,
143    scrollbar_width_px: f32,
144    has_other_scrollbar: bool,
145    button_size: f32,
146) -> ScrollbarGeometry {
147    let (track_total, viewport_length, content_length, track_rect) = match orientation {
148        ScrollbarOrientation::Vertical => {
149            let track_total = if has_other_scrollbar {
150                inner_rect.size.height - scrollbar_width_px
151            } else {
152                inner_rect.size.height
153            };
154            let track_rect = LogicalRect {
155                origin: LogicalPosition::new(
156                    inner_rect.origin.x + inner_rect.size.width - scrollbar_width_px,
157                    inner_rect.origin.y,
158                ),
159                size: LogicalSize::new(scrollbar_width_px, track_total),
160            };
161            (track_total, inner_rect.size.height, content_size.height, track_rect)
162        }
163        ScrollbarOrientation::Horizontal => {
164            let track_total = if has_other_scrollbar {
165                inner_rect.size.width - scrollbar_width_px
166            } else {
167                inner_rect.size.width
168            };
169            let track_rect = LogicalRect {
170                origin: LogicalPosition::new(
171                    inner_rect.origin.x,
172                    inner_rect.origin.y + inner_rect.size.height - scrollbar_width_px,
173                ),
174                size: LogicalSize::new(track_total, scrollbar_width_px),
175            };
176            (track_total, inner_rect.size.width, content_size.width, track_rect)
177        }
178    };
179
180    compute_thumb_geometry(
181        orientation,
182        track_rect,
183        track_total,
184        viewport_length,
185        content_length,
186        button_size,
187        scrollbar_width_px,
188        scroll_offset,
189    )
190}
191
192#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
193fn compute_thumb_geometry(
194    orientation: ScrollbarOrientation,
195    track_rect: LogicalRect,
196    track_total: f32,
197    viewport_length: f32,
198    content_length: f32,
199    button_size: f32,
200    scrollbar_width_px: f32,
201    scroll_offset: f32,
202) -> ScrollbarGeometry {
203    let usable_track_length = (track_total - 2.0 * button_size).max(0.0);
204
205    let thumb_size_ratio = if content_length > 0.0 {
206        (viewport_length / content_length).min(1.0)
207    } else {
208        1.0
209    };
210    let thumb_length = (usable_track_length * thumb_size_ratio)
211        .max(scrollbar_width_px * 2.0)
212        .min(usable_track_length);
213
214    let max_scroll = (content_length - viewport_length).max(0.0);
215    let scroll_ratio = if max_scroll > 0.0 {
216        (scroll_offset.abs() / max_scroll).clamp(0.0, 1.0)
217    } else {
218        0.0
219    };
220
221    let thumb_offset = (usable_track_length - thumb_length) * scroll_ratio;
222
223    ScrollbarGeometry {
224        orientation,
225        track_rect,
226        button_size,
227        usable_track_length,
228        thumb_length,
229        thumb_size_ratio,
230        scroll_ratio,
231        thumb_offset,
232        max_scroll,
233        width_px: scrollbar_width_px,
234    }
235}
236
237#[cfg(test)]
238mod autotest_generated {
239    use super::*;
240
241    // ---------------------------------------------------------------------
242    // helpers
243    // ---------------------------------------------------------------------
244
245    fn rect(x: f32, y: f32, w: f32, h: f32) -> LogicalRect {
246        LogicalRect::new(LogicalPosition::new(x, y), LogicalSize::new(w, h))
247    }
248
249    fn reqs(width: f32, height: f32) -> ScrollbarRequirements {
250        ScrollbarRequirements {
251            needs_horizontal: height > 0.0,
252            needs_vertical: width > 0.0,
253            scrollbar_width: width,
254            scrollbar_height: height,
255            visual_width_px: 15.0,
256        }
257    }
258
259    #[track_caller]
260    fn approx(actual: f32, expected: f32) {
261        assert!(
262            (actual - expected).abs() <= 1e-3,
263            "expected {expected}, got {actual}"
264        );
265    }
266
267    // ---------------------------------------------------------------------
268    // ScrollbarRequirements::needs_reflow  (getter / predicate)
269    // ---------------------------------------------------------------------
270
271    #[test]
272    fn needs_reflow_default_instance_is_false() {
273        assert!(!ScrollbarRequirements::default().needs_reflow());
274    }
275
276    #[test]
277    fn needs_reflow_true_when_either_axis_reserves_space() {
278        assert!(reqs(15.0, 0.0).needs_reflow());
279        assert!(reqs(0.0, 15.0).needs_reflow());
280        assert!(reqs(15.0, 15.0).needs_reflow());
281        assert!(!reqs(0.0, 0.0).needs_reflow());
282    }
283
284    #[test]
285    fn needs_reflow_is_false_for_overlay_scrollbars() {
286        // Overlay scrollbars are visible (visual_width_px > 0) but reserve no
287        // layout space, so they must never trigger a reflow.
288        let overlay = ScrollbarRequirements {
289            needs_horizontal: true,
290            needs_vertical: true,
291            scrollbar_width: 0.0,
292            scrollbar_height: 0.0,
293            visual_width_px: 12.0,
294        };
295        assert!(!overlay.needs_reflow());
296    }
297
298    #[test]
299    fn needs_reflow_does_not_panic_on_extreme_values() {
300        // NaN compares false against every bound, so it reads as "no space reserved".
301        assert!(!reqs(f32::NAN, f32::NAN).needs_reflow());
302        // Negative / -inf reservations are nonsense but must not report a reflow.
303        assert!(!reqs(-1.0, -1.0).needs_reflow());
304        assert!(!reqs(f32::NEG_INFINITY, f32::NEG_INFINITY).needs_reflow());
305        assert!(!reqs(f32::MIN, f32::MIN).needs_reflow());
306        assert!(!reqs(-0.0, -0.0).needs_reflow());
307        // Anything strictly positive, however small or large, does.
308        assert!(reqs(f32::MIN_POSITIVE, 0.0).needs_reflow());
309        assert!(reqs(0.0, f32::MAX).needs_reflow());
310        assert!(reqs(f32::INFINITY, 0.0).needs_reflow());
311    }
312
313    // ---------------------------------------------------------------------
314    // ScrollbarRequirements::shrink_size  (numeric)
315    // ---------------------------------------------------------------------
316
317    #[test]
318    fn shrink_size_zero_reservation_is_the_identity() {
319        let out = reqs(0.0, 0.0).shrink_size(LogicalSize::new(800.0, 600.0));
320        approx(out.width, 800.0);
321        approx(out.height, 600.0);
322    }
323
324    #[test]
325    fn shrink_size_subtracts_each_axis_independently() {
326        let out = reqs(15.0, 0.0).shrink_size(LogicalSize::new(100.0, 200.0));
327        approx(out.width, 85.0);
328        approx(out.height, 200.0);
329
330        let out = reqs(0.0, 15.0).shrink_size(LogicalSize::new(100.0, 200.0));
331        approx(out.width, 100.0);
332        approx(out.height, 185.0);
333    }
334
335    #[test]
336    fn shrink_size_clamps_at_zero_and_never_returns_negative() {
337        let out = reqs(100.0, 100.0).shrink_size(LogicalSize::new(10.0, 10.0));
338        approx(out.width, 0.0);
339        approx(out.height, 0.0);
340        assert!(out.width >= 0.0 && out.height >= 0.0);
341
342        // Zero-sized content box stays at zero.
343        let out = reqs(15.0, 15.0).shrink_size(LogicalSize::new(0.0, 0.0));
344        approx(out.width, 0.0);
345        approx(out.height, 0.0);
346    }
347
348    #[test]
349    fn shrink_size_at_float_min_max_does_not_panic() {
350        // MAX - MAX == 0.0
351        let out = reqs(f32::MAX, f32::MAX).shrink_size(LogicalSize::new(f32::MAX, f32::MAX));
352        approx(out.width, 0.0);
353        approx(out.height, 0.0);
354
355        // MAX with nothing reserved survives unchanged (no overflow).
356        let out = reqs(0.0, 0.0).shrink_size(LogicalSize::new(f32::MAX, f32::MAX));
357        assert!(out.width.is_finite() && out.height.is_finite());
358        approx(out.width / f32::MAX, 1.0);
359
360        // A negative (MIN) input size is clamped up to zero.
361        let out = reqs(0.0, 0.0).shrink_size(LogicalSize::new(f32::MIN, f32::MIN));
362        approx(out.width, 0.0);
363        approx(out.height, 0.0);
364    }
365
366    #[test]
367    fn shrink_size_with_nan_or_infinite_inputs_is_defined() {
368        // NaN propagates into the subtraction, but f32::max(NaN, 0.0) == 0.0,
369        // so the result is a defined (zero) size rather than a NaN size.
370        let out = reqs(f32::NAN, f32::NAN).shrink_size(LogicalSize::new(100.0, 100.0));
371        approx(out.width, 0.0);
372        approx(out.height, 0.0);
373
374        let out = reqs(0.0, 0.0).shrink_size(LogicalSize::new(f32::NAN, f32::NAN));
375        approx(out.width, 0.0);
376        approx(out.height, 0.0);
377
378        // inf - inf == NaN -> clamped to 0.0
379        let out =
380            reqs(f32::INFINITY, f32::INFINITY).shrink_size(LogicalSize::new(f32::INFINITY, f32::INFINITY));
381        approx(out.width, 0.0);
382        approx(out.height, 0.0);
383
384        // An infinite content box with a finite reservation stays infinite.
385        let out = reqs(15.0, 15.0).shrink_size(LogicalSize::new(f32::INFINITY, f32::INFINITY));
386        assert!(out.width.is_infinite() && out.width.is_sign_positive());
387        assert!(out.height.is_infinite() && out.height.is_sign_positive());
388    }
389
390    #[test]
391    fn shrink_size_with_negative_reservation_grows_the_box() {
392        // Characterization: shrink_size does not clamp the *reservation*, only the
393        // result. A negative reserved width therefore enlarges the content box.
394        // Negative reservations are not producible by the CSS resolver today; this
395        // pins the behaviour so a future clamp is a deliberate, visible change.
396        let out = reqs(-10.0, -10.0).shrink_size(LogicalSize::new(100.0, 100.0));
397        approx(out.width, 110.0);
398        approx(out.height, 110.0);
399    }
400
401    #[test]
402    fn shrink_size_is_identity_exactly_when_no_reflow_is_needed() {
403        // Property: for the non-negative reservations the solver can actually
404        // produce, !needs_reflow() <=> shrink_size() leaves the size untouched.
405        for w in [0.0_f32, 1.0, 8.0, 15.0, 100.0] {
406            for h in [0.0_f32, 1.0, 8.0, 15.0, 100.0] {
407                let r = reqs(w, h);
408                let size = LogicalSize::new(500.0, 500.0);
409                let out = r.shrink_size(size);
410                let unchanged = (out.width - size.width).abs() < f32::EPSILON
411                    && (out.height - size.height).abs() < f32::EPSILON;
412                assert_eq!(!r.needs_reflow(), unchanged, "w={w} h={h}");
413            }
414        }
415    }
416
417    // ---------------------------------------------------------------------
418    // compute_scrollbar_geometry  (numeric)
419    // ---------------------------------------------------------------------
420
421    #[test]
422    fn vertical_geometry_places_the_track_on_the_right_inner_edge() {
423        let g = compute_scrollbar_geometry(
424            ScrollbarOrientation::Vertical,
425            rect(10.0, 20.0, 100.0, 200.0),
426            LogicalSize::new(100.0, 400.0),
427            0.0,
428            15.0,
429            false,
430        );
431        assert_eq!(g.orientation, ScrollbarOrientation::Vertical);
432        approx(g.track_rect.origin.x, 10.0 + 100.0 - 15.0);
433        approx(g.track_rect.origin.y, 20.0);
434        approx(g.track_rect.size.width, 15.0);
435        approx(g.track_rect.size.height, 200.0);
436        approx(g.button_size, 15.0);
437        approx(g.usable_track_length, 200.0 - 2.0 * 15.0);
438        approx(g.thumb_size_ratio, 0.5);
439        approx(g.thumb_length, 85.0);
440        approx(g.max_scroll, 200.0);
441        approx(g.scroll_ratio, 0.0);
442        approx(g.thumb_offset, 0.0);
443        approx(g.width_px, 15.0);
444    }
445
446    #[test]
447    fn horizontal_geometry_places_the_track_on_the_bottom_inner_edge() {
448        let g = compute_scrollbar_geometry(
449            ScrollbarOrientation::Horizontal,
450            rect(10.0, 20.0, 100.0, 200.0),
451            LogicalSize::new(300.0, 200.0),
452            100.0,
453            15.0,
454            false,
455        );
456        assert_eq!(g.orientation, ScrollbarOrientation::Horizontal);
457        approx(g.track_rect.origin.x, 10.0);
458        approx(g.track_rect.origin.y, 20.0 + 200.0 - 15.0);
459        approx(g.track_rect.size.width, 100.0);
460        approx(g.track_rect.size.height, 15.0);
461        approx(g.usable_track_length, 70.0);
462        approx(g.thumb_size_ratio, 1.0 / 3.0);
463        // 70 * 0.333 = 23.3 -> lifted to the 2*width minimum (30)
464        approx(g.thumb_length, 30.0);
465        approx(g.max_scroll, 200.0);
466        approx(g.scroll_ratio, 0.5);
467        approx(g.thumb_offset, (70.0 - 30.0) * 0.5);
468    }
469
470    #[test]
471    fn the_other_scrollbar_steals_exactly_one_width_from_the_track() {
472        let without = compute_scrollbar_geometry(
473            ScrollbarOrientation::Vertical,
474            rect(0.0, 0.0, 100.0, 200.0),
475            LogicalSize::new(100.0, 400.0),
476            0.0,
477            15.0,
478            false,
479        );
480        let with = compute_scrollbar_geometry(
481            ScrollbarOrientation::Vertical,
482            rect(0.0, 0.0, 100.0, 200.0),
483            LogicalSize::new(100.0, 400.0),
484            0.0,
485            15.0,
486            true,
487        );
488        approx(without.track_rect.size.height - with.track_rect.size.height, 15.0);
489        approx(without.usable_track_length - with.usable_track_length, 15.0);
490        approx(with.usable_track_length, 200.0 - 15.0 - 30.0);
491    }
492
493    #[test]
494    fn compute_scrollbar_geometry_defaults_to_button_size_equal_to_width() {
495        for orientation in [ScrollbarOrientation::Vertical, ScrollbarOrientation::Horizontal] {
496            let a = compute_scrollbar_geometry(
497                orientation,
498                rect(5.0, 7.0, 120.0, 240.0),
499                LogicalSize::new(500.0, 900.0),
500                33.0,
501                17.0,
502                true,
503            );
504            let b = compute_scrollbar_geometry_with_button_size(
505                orientation,
506                rect(5.0, 7.0, 120.0, 240.0),
507                LogicalSize::new(500.0, 900.0),
508                33.0,
509                17.0,
510                true,
511                17.0,
512            );
513            approx(a.button_size, b.button_size);
514            approx(a.usable_track_length, b.usable_track_length);
515            approx(a.thumb_length, b.thumb_length);
516            approx(a.thumb_offset, b.thumb_offset);
517            approx(a.max_scroll, b.max_scroll);
518        }
519    }
520
521    #[test]
522    fn everything_zero_yields_an_all_zero_geometry() {
523        let g = compute_scrollbar_geometry(
524            ScrollbarOrientation::Vertical,
525            LogicalRect::zero(),
526            LogicalSize::new(0.0, 0.0),
527            0.0,
528            0.0,
529            false,
530        );
531        approx(g.usable_track_length, 0.0);
532        approx(g.thumb_length, 0.0);
533        approx(g.thumb_offset, 0.0);
534        approx(g.max_scroll, 0.0);
535        approx(g.scroll_ratio, 0.0);
536        // Zero content means "everything fits" -> the thumb covers the whole track.
537        approx(g.thumb_size_ratio, 1.0);
538    }
539
540    #[test]
541    fn content_smaller_than_viewport_is_not_scrollable() {
542        let g = compute_scrollbar_geometry(
543            ScrollbarOrientation::Vertical,
544            rect(0.0, 0.0, 100.0, 400.0),
545            LogicalSize::new(100.0, 50.0),
546            9999.0, // bogus scroll offset on a non-scrollable box
547            15.0,
548            false,
549        );
550        approx(g.thumb_size_ratio, 1.0);
551        approx(g.max_scroll, 0.0);
552        approx(g.scroll_ratio, 0.0);
553        approx(g.thumb_offset, 0.0);
554        approx(g.thumb_length, g.usable_track_length);
555    }
556
557    #[test]
558    fn overscroll_clamps_the_thumb_to_the_end_of_the_track() {
559        let g = compute_scrollbar_geometry(
560            ScrollbarOrientation::Vertical,
561            rect(0.0, 0.0, 100.0, 200.0),
562            LogicalSize::new(100.0, 400.0),
563            1.0e9, // far past max_scroll (200)
564            15.0,
565            false,
566        );
567        approx(g.scroll_ratio, 1.0);
568        approx(g.thumb_offset, g.usable_track_length - g.thumb_length);
569        assert!(g.thumb_offset + g.thumb_length <= g.usable_track_length + 1e-3);
570    }
571
572    #[test]
573    fn negative_scroll_offsets_are_taken_by_absolute_value() {
574        let pos = compute_scrollbar_geometry(
575            ScrollbarOrientation::Vertical,
576            rect(0.0, 0.0, 100.0, 200.0),
577            LogicalSize::new(100.0, 400.0),
578            50.0,
579            15.0,
580            false,
581        );
582        let neg = compute_scrollbar_geometry(
583            ScrollbarOrientation::Vertical,
584            rect(0.0, 0.0, 100.0, 200.0),
585            LogicalSize::new(100.0, 400.0),
586            -50.0,
587            15.0,
588            false,
589        );
590        approx(neg.scroll_ratio, pos.scroll_ratio);
591        approx(neg.thumb_offset, pos.thumb_offset);
592        assert!(neg.thumb_offset >= 0.0);
593    }
594
595    #[test]
596    fn very_long_content_lifts_the_thumb_to_the_minimum_length() {
597        let g = compute_scrollbar_geometry(
598            ScrollbarOrientation::Vertical,
599            rect(0.0, 0.0, 100.0, 200.0),
600            LogicalSize::new(100.0, 1.0e6),
601            0.0,
602            15.0,
603            false,
604        );
605        // proportional thumb would be ~0.03px; the 2*width floor kicks in
606        approx(g.thumb_length, 30.0);
607        assert!(g.thumb_length <= g.usable_track_length);
608    }
609
610    #[test]
611    fn the_minimum_thumb_length_is_capped_by_the_usable_track() {
612        // Track (40px) is barely bigger than the two buttons (2*15) -> 10px usable.
613        // The 30px minimum thumb must be clamped down, never overflow the track.
614        let g = compute_scrollbar_geometry(
615            ScrollbarOrientation::Vertical,
616            rect(0.0, 0.0, 100.0, 40.0),
617            LogicalSize::new(100.0, 1000.0),
618            500.0,
619            15.0,
620            false,
621        );
622        approx(g.usable_track_length, 10.0);
623        approx(g.thumb_length, 10.0);
624        approx(g.thumb_offset, 0.0);
625        assert!(g.thumb_offset + g.thumb_length <= g.usable_track_length + 1e-3);
626    }
627
628    #[test]
629    fn a_container_thinner_than_its_scrollbar_still_produces_safe_lengths() {
630        // 10px tall container, 15px scrollbar, corner reserved => track_total = -5.
631        // Characterization: the *track rect* is allowed to go negative here (it is
632        // clipped by the painter), but every derived length stays non-negative.
633        let g = compute_scrollbar_geometry(
634            ScrollbarOrientation::Vertical,
635            rect(0.0, 0.0, 100.0, 10.0),
636            LogicalSize::new(100.0, 400.0),
637            10.0,
638            15.0,
639            true,
640        );
641        assert!(g.track_rect.size.height < 0.0, "track rect goes negative");
642        approx(g.usable_track_length, 0.0);
643        approx(g.thumb_length, 0.0);
644        approx(g.thumb_offset, 0.0);
645    }
646
647    #[test]
648    fn zero_button_size_gives_the_whole_track_to_the_thumb() {
649        let g = compute_scrollbar_geometry_with_button_size(
650            ScrollbarOrientation::Vertical,
651            rect(0.0, 0.0, 100.0, 200.0),
652            LogicalSize::new(100.0, 400.0),
653            0.0,
654            8.0,
655            false,
656            0.0, // overlay scrollbar: no arrow buttons
657        );
658        approx(g.button_size, 0.0);
659        approx(g.usable_track_length, 200.0);
660        approx(g.thumb_length, 100.0);
661        approx(g.width_px, 8.0);
662    }
663
664    #[test]
665    fn zero_width_scrollbar_does_not_panic() {
666        let g = compute_scrollbar_geometry(
667            ScrollbarOrientation::Horizontal,
668            rect(0.0, 0.0, 100.0, 200.0),
669            LogicalSize::new(400.0, 200.0),
670            100.0,
671            0.0,
672            false,
673        );
674        approx(g.usable_track_length, 100.0);
675        approx(g.width_px, 0.0);
676        approx(g.thumb_size_ratio, 0.25);
677        approx(g.thumb_length, 25.0);
678        assert!(g.thumb_offset >= 0.0);
679    }
680
681    #[test]
682    fn negative_content_size_is_treated_as_unscrollable() {
683        let g = 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        // `content_length > 0.0` is false -> ratio 1.0, no scrolling.
692        approx(g.thumb_size_ratio, 1.0);
693        approx(g.max_scroll, 0.0);
694        approx(g.scroll_ratio, 0.0);
695        approx(g.thumb_offset, 0.0);
696        approx(g.thumb_length, g.usable_track_length);
697    }
698
699    #[test]
700    fn a_negative_viewport_still_produces_non_negative_lengths() {
701        // Degenerate inner rect (negative height). The size *ratio* goes negative,
702        // but the lengths that reach the painter must not.
703        let g = compute_scrollbar_geometry(
704            ScrollbarOrientation::Vertical,
705            rect(0.0, 0.0, 100.0, -100.0),
706            LogicalSize::new(100.0, 200.0),
707            50.0,
708            15.0,
709            false,
710        );
711        approx(g.thumb_size_ratio, -0.5);
712        approx(g.usable_track_length, 0.0);
713        approx(g.thumb_length, 0.0);
714        approx(g.thumb_offset, 0.0);
715        assert!(g.max_scroll >= 0.0);
716    }
717
718    #[test]
719    fn float_max_inputs_stay_finite() {
720        let g = compute_scrollbar_geometry(
721            ScrollbarOrientation::Vertical,
722            rect(0.0, 0.0, f32::MAX, f32::MAX),
723            LogicalSize::new(f32::MAX, f32::MAX),
724            f32::MAX,
725            15.0,
726            true,
727        );
728        assert!(g.usable_track_length.is_finite());
729        assert!(g.thumb_length.is_finite());
730        assert!(g.thumb_offset.is_finite());
731        assert!(g.max_scroll.is_finite());
732        assert!(g.scroll_ratio.is_finite());
733        assert!(g.thumb_size_ratio.is_finite());
734        approx(g.thumb_size_ratio, 1.0);
735        approx(g.max_scroll, 0.0);
736        approx(g.thumb_offset, 0.0);
737    }
738
739    #[test]
740    fn infinite_content_length_does_not_panic() {
741        let g = compute_scrollbar_geometry(
742            ScrollbarOrientation::Vertical,
743            rect(0.0, 0.0, 100.0, 200.0),
744            LogicalSize::new(100.0, f32::INFINITY),
745            100.0,
746            15.0,
747            false,
748        );
749        approx(g.thumb_size_ratio, 0.0);
750        approx(g.thumb_length, 30.0); // floored at 2 * width
751        assert!(g.max_scroll.is_infinite());
752        // finite_offset / inf == 0 -> the thumb parks at the start
753        approx(g.scroll_ratio, 0.0);
754        approx(g.thumb_offset, 0.0);
755    }
756
757    #[test]
758    fn nan_scrollbar_width_does_not_panic_and_collapses_the_track() {
759        let g = compute_scrollbar_geometry(
760            ScrollbarOrientation::Vertical,
761            rect(0.0, 0.0, 100.0, 200.0),
762            LogicalSize::new(100.0, 400.0),
763            0.0,
764            f32::NAN,
765            false,
766        );
767        // NaN width => NaN button size => the usable track clamps to 0 and the
768        // thumb collapses. Nothing paints, but nothing panics either.
769        approx(g.usable_track_length, 0.0);
770        approx(g.thumb_length, 0.0);
771        approx(g.thumb_offset, 0.0);
772        assert!(g.width_px.is_nan());
773        assert!(g.track_rect.origin.x.is_nan());
774    }
775
776    #[test]
777    fn nan_content_size_does_not_panic() {
778        let g = compute_scrollbar_geometry(
779            ScrollbarOrientation::Vertical,
780            rect(0.0, 0.0, 100.0, 200.0),
781            LogicalSize::new(100.0, f32::NAN),
782            50.0,
783            15.0,
784            false,
785        );
786        // `NaN > 0.0` is false -> ratio 1.0; `NaN - v` is NaN -> max_scroll 0.
787        approx(g.thumb_size_ratio, 1.0);
788        approx(g.max_scroll, 0.0);
789        approx(g.scroll_ratio, 0.0);
790        approx(g.thumb_offset, 0.0);
791        approx(g.thumb_length, g.usable_track_length);
792        assert!(g.thumb_length.is_finite());
793    }
794
795    #[test]
796    fn infinite_scroll_offsets_saturate_the_scroll_ratio() {
797        for offset in [f32::INFINITY, f32::NEG_INFINITY] {
798            let g = compute_scrollbar_geometry(
799                ScrollbarOrientation::Vertical,
800                rect(0.0, 0.0, 100.0, 200.0),
801                LogicalSize::new(100.0, 400.0),
802                offset,
803                15.0,
804                false,
805            );
806            approx(g.scroll_ratio, 1.0);
807            approx(g.thumb_offset, g.usable_track_length - g.thumb_length);
808        }
809    }
810
811    #[test]
812    fn nan_scroll_offset_leaks_nan_into_scroll_ratio_and_thumb_offset() {
813        // FINDING (characterization, not a panic): `f32::clamp` returns NaN for a
814        // NaN input, so a NaN scroll offset survives into `scroll_ratio` and then
815        // `thumb_offset`. Every other field stays well-defined. A NaN offset would
816        // paint the thumb at an undefined position rather than clamping to 0.
817        let g = compute_scrollbar_geometry(
818            ScrollbarOrientation::Vertical,
819            rect(0.0, 0.0, 100.0, 200.0),
820            LogicalSize::new(100.0, 400.0),
821            f32::NAN,
822            15.0,
823            false,
824        );
825        assert!(g.scroll_ratio.is_nan());
826        assert!(g.thumb_offset.is_nan());
827        approx(g.usable_track_length, 170.0);
828        approx(g.thumb_length, 85.0);
829        approx(g.max_scroll, 200.0);
830    }
831
832    #[test]
833    fn infinite_viewport_leaks_nan_into_thumb_offset() {
834        // FINDING (characterization, not a panic): an infinite inner rect makes both
835        // `usable_track_length` and `thumb_length` infinite, so `usable - thumb` is
836        // NaN and the offset follows. No panic; the value is simply undefined.
837        let g = compute_scrollbar_geometry(
838            ScrollbarOrientation::Vertical,
839            rect(0.0, 0.0, 100.0, f32::INFINITY),
840            LogicalSize::new(100.0, 1000.0),
841            0.0,
842            15.0,
843            false,
844        );
845        assert!(g.usable_track_length.is_infinite());
846        assert!(g.thumb_length.is_infinite());
847        assert!(g.thumb_offset.is_nan());
848        approx(g.max_scroll, 0.0);
849        approx(g.scroll_ratio, 0.0);
850    }
851
852    // ---------------------------------------------------------------------
853    // compute_thumb_geometry  (private, numeric)
854    // ---------------------------------------------------------------------
855
856    #[test]
857    fn thumb_geometry_math_is_exact_for_a_known_case() {
858        let track = rect(1.0, 2.0, 3.0, 4.0);
859        let g = compute_thumb_geometry(
860            ScrollbarOrientation::Horizontal,
861            track,
862            200.0, // track_total
863            100.0, // viewport_length
864            200.0, // content_length
865            10.0,  // button_size
866            10.0,  // scrollbar_width_px
867            50.0,  // scroll_offset
868        );
869        approx(g.usable_track_length, 180.0); // 200 - 2*10
870        approx(g.thumb_size_ratio, 0.5); // 100 / 200
871        approx(g.thumb_length, 90.0); // 180 * 0.5
872        approx(g.max_scroll, 100.0); // 200 - 100
873        approx(g.scroll_ratio, 0.5); // 50 / 100
874        approx(g.thumb_offset, 45.0); // (180 - 90) * 0.5
875        // the track rect is passed straight through, never recomputed
876        approx(g.track_rect.origin.x, track.origin.x);
877        approx(g.track_rect.size.height, track.size.height);
878        assert_eq!(g.orientation, ScrollbarOrientation::Horizontal);
879    }
880
881    #[test]
882    fn thumb_geometry_buttons_larger_than_the_track_collapse_the_usable_length() {
883        let g = compute_thumb_geometry(
884            ScrollbarOrientation::Vertical,
885            LogicalRect::zero(),
886            20.0,   // track_total
887            100.0,  // viewport_length
888            1000.0, // content_length
889            500.0,  // button_size >> track
890            15.0,
891            250.0,
892        );
893        approx(g.usable_track_length, 0.0);
894        approx(g.thumb_length, 0.0);
895        approx(g.thumb_offset, 0.0);
896        assert!(g.scroll_ratio >= 0.0 && g.scroll_ratio <= 1.0);
897    }
898
899    #[test]
900    fn thumb_geometry_ignores_scroll_offset_when_content_fits() {
901        let g = compute_thumb_geometry(
902            ScrollbarOrientation::Vertical,
903            LogicalRect::zero(),
904            200.0,
905            200.0, // viewport == content
906            200.0,
907            10.0,
908            10.0,
909            1.0e9, // absurd offset
910        );
911        approx(g.max_scroll, 0.0);
912        approx(g.scroll_ratio, 0.0);
913        approx(g.thumb_offset, 0.0);
914        approx(g.thumb_size_ratio, 1.0);
915        approx(g.thumb_length, g.usable_track_length);
916    }
917
918    #[test]
919    fn thumb_geometry_clamps_an_oversized_viewport_ratio_to_one() {
920        let g = compute_thumb_geometry(
921            ScrollbarOrientation::Vertical,
922            LogicalRect::zero(),
923            200.0,
924            400.0, // viewport bigger than content
925            100.0,
926            10.0,
927            10.0,
928            0.0,
929        );
930        approx(g.thumb_size_ratio, 1.0);
931        approx(g.thumb_length, g.usable_track_length);
932        approx(g.max_scroll, 0.0);
933    }
934
935    #[test]
936    fn thumb_geometry_survives_an_all_nan_call() {
937        let g = compute_thumb_geometry(
938            ScrollbarOrientation::Vertical,
939            LogicalRect::zero(),
940            f32::NAN,
941            f32::NAN,
942            f32::NAN,
943            f32::NAN,
944            f32::NAN,
945            f32::NAN,
946        );
947        // max()/min() drop NaN in favour of the other operand, and the
948        // `content_length > 0.0` / `max_scroll > 0.0` guards both read false.
949        approx(g.usable_track_length, 0.0);
950        approx(g.thumb_length, 0.0);
951        approx(g.thumb_size_ratio, 1.0);
952        approx(g.max_scroll, 0.0);
953        approx(g.scroll_ratio, 0.0);
954        approx(g.thumb_offset, 0.0);
955    }
956
957    // ---------------------------------------------------------------------
958    // round-trip + invariants
959    // ---------------------------------------------------------------------
960
961    #[test]
962    fn thumb_offset_round_trips_back_to_the_scroll_offset() {
963        // This is the inverse used by `handle_scrollbar_drag`: dragging the thumb to
964        // `thumb_offset` must map back to the scroll offset that produced it.
965        for &offset in &[0.0_f32, 25.0, 50.0, 100.0, 150.0, 200.0] {
966            let g = compute_scrollbar_geometry(
967                ScrollbarOrientation::Vertical,
968                rect(0.0, 0.0, 100.0, 200.0),
969                LogicalSize::new(100.0, 400.0),
970                offset,
971                15.0,
972                false,
973            );
974            let drag_range = g.usable_track_length - g.thumb_length;
975            assert!(drag_range > 0.0);
976            let recovered = (g.thumb_offset / drag_range) * g.max_scroll;
977            approx(recovered, offset);
978        }
979    }
980
981    #[test]
982    fn thumb_offset_is_monotonic_in_the_scroll_offset() {
983        let mut previous = f32::NEG_INFINITY;
984        for step in 0..=20 {
985            let offset = step as f32 * 15.0; // 0 .. 300, past max_scroll (200)
986            let g = compute_scrollbar_geometry(
987                ScrollbarOrientation::Horizontal,
988                rect(0.0, 0.0, 200.0, 100.0),
989                LogicalSize::new(400.0, 100.0),
990                offset,
991                15.0,
992                false,
993            );
994            assert!(
995                g.thumb_offset >= previous - 1e-4,
996                "thumb went backwards at offset {offset}: {} < {previous}",
997                g.thumb_offset
998            );
999            previous = g.thumb_offset;
1000        }
1001    }
1002
1003    #[test]
1004    fn geometry_invariants_hold_across_a_finite_input_grid() {
1005        let orientations = [ScrollbarOrientation::Vertical, ScrollbarOrientation::Horizontal];
1006        let rects = [
1007            rect(0.0, 0.0, 0.0, 0.0),
1008            rect(0.0, 0.0, 1.0, 1.0),
1009            rect(-50.0, -50.0, 100.0, 200.0),
1010            rect(10.0, 20.0, 800.0, 600.0),
1011            rect(0.0, 0.0, f32::MAX, f32::MAX),
1012        ];
1013        let contents = [
1014            LogicalSize::new(0.0, 0.0),
1015            LogicalSize::new(1.0, 1.0),
1016            LogicalSize::new(100.0, 200.0),
1017            LogicalSize::new(1.0e9, 1.0e9),
1018            LogicalSize::new(f32::MAX, f32::MAX),
1019        ];
1020        let offsets = [0.0_f32, -1.0, 1.0, 12_345.0, -12_345.0, f32::MAX];
1021        let widths = [0.0_f32, 1.0, 8.0, 15.0, 1000.0];
1022        let buttons = [0.0_f32, 1.0, 15.0, 1000.0];
1023
1024        for &orientation in &orientations {
1025            for &inner in &rects {
1026                for &content in &contents {
1027                    for &offset in &offsets {
1028                        for &width in &widths {
1029                            for &button in &buttons {
1030                                for &other in &[false, true] {
1031                                    let g = compute_scrollbar_geometry_with_button_size(
1032                                        orientation,
1033                                        inner,
1034                                        content,
1035                                        offset,
1036                                        width,
1037                                        other,
1038                                        button,
1039                                    );
1040                                    let ctx = format!(
1041                                        "inner={inner:?} content={content:?} offset={offset} \
1042                                         width={width} button={button} other={other}"
1043                                    );
1044                                    assert!(g.usable_track_length.is_finite(), "{ctx}");
1045                                    assert!(g.thumb_length.is_finite(), "{ctx}");
1046                                    assert!(g.thumb_offset.is_finite(), "{ctx}");
1047                                    assert!(g.max_scroll.is_finite(), "{ctx}");
1048                                    assert!(g.usable_track_length >= 0.0, "{ctx}");
1049                                    assert!(g.max_scroll >= 0.0, "{ctx}");
1050                                    assert!(g.thumb_offset >= 0.0, "{ctx}");
1051                                    assert!(
1052                                        g.thumb_length >= 0.0
1053                                            && g.thumb_length <= g.usable_track_length,
1054                                        "thumb escapes the track: {ctx}"
1055                                    );
1056                                    assert!(
1057                                        (0.0..=1.0).contains(&g.thumb_size_ratio),
1058                                        "size ratio out of range: {ctx}"
1059                                    );
1060                                    assert!(
1061                                        (0.0..=1.0).contains(&g.scroll_ratio),
1062                                        "scroll ratio out of range: {ctx}"
1063                                    );
1064                                    let slack = g.usable_track_length
1065                                        + g.usable_track_length.abs() * 1e-5
1066                                        + 1e-3;
1067                                    assert!(
1068                                        g.thumb_offset + g.thumb_length <= slack,
1069                                        "thumb overruns the track end: {ctx}"
1070                                    );
1071                                }
1072                            }
1073                        }
1074                    }
1075                }
1076            }
1077        }
1078    }
1079
1080    #[test]
1081    fn default_geometry_is_inert() {
1082        let g = ScrollbarGeometry::default();
1083        assert_eq!(g.orientation, ScrollbarOrientation::Vertical);
1084        approx(g.button_size, 0.0);
1085        approx(g.usable_track_length, 0.0);
1086        approx(g.thumb_length, 0.0);
1087        approx(g.thumb_size_ratio, 0.0);
1088        approx(g.scroll_ratio, 0.0);
1089        approx(g.thumb_offset, 0.0);
1090        approx(g.max_scroll, 0.0);
1091        approx(g.width_px, 0.0);
1092        assert_eq!(g.track_rect, LogicalRect::zero());
1093    }
1094}