1use azul_core::geom::{LogicalPosition, LogicalRect, LogicalSize};
13use azul_core::dom::ScrollbarOrientation;
14
15#[derive(Copy, Debug, Clone, Default)]
18#[repr(C)]
19pub struct ScrollbarRequirements {
20 pub needs_horizontal: bool,
21 pub needs_vertical: bool,
22 pub scrollbar_width: f32,
24 pub scrollbar_height: f32,
26 pub visual_width_px: f32,
29}
30
31impl ScrollbarRequirements {
32 #[must_use] pub fn needs_reflow(&self) -> bool {
35 self.scrollbar_width > 0.0 || self.scrollbar_height > 0.0
36 }
37
38 #[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#[derive(Debug, Clone, Copy)]
59pub struct ScrollbarGeometry {
60 pub orientation: ScrollbarOrientation,
62 pub track_rect: LogicalRect,
64 pub button_size: f32,
66 pub usable_track_length: f32,
69 pub thumb_length: f32,
71 pub thumb_size_ratio: f32,
73 pub scroll_ratio: f32,
75 pub thumb_offset: f32,
77 pub max_scroll: f32,
79 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#[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 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, )
134}
135
136#[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)] fn 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 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 #[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 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 assert!(!reqs(f32::NAN, f32::NAN).needs_reflow());
302 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 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 #[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 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 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 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 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 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 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 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 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 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 #[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 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 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, 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, 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 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 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 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, );
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 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 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); assert!(g.max_scroll.is_infinite());
752 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 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 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 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 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 #[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, 100.0, 200.0, 10.0, 10.0, 50.0, );
869 approx(g.usable_track_length, 180.0); approx(g.thumb_size_ratio, 0.5); approx(g.thumb_length, 90.0); approx(g.max_scroll, 100.0); approx(g.scroll_ratio, 0.5); approx(g.thumb_offset, 45.0); 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, 100.0, 1000.0, 500.0, 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, 200.0,
907 10.0,
908 10.0,
909 1.0e9, );
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, 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 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 #[test]
962 fn thumb_offset_round_trips_back_to_the_scroll_offset() {
963 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; 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}