ailloli_ui_core/scroll.rs
1//! Pure logical scroll metrics, clamping, reveal, and wheel normalization.
2
3use crate::event::{Modifiers, WheelDelta};
4use crate::{Offset, Point, Rect, Size};
5
6/// Scrollable axes for a viewport.
7///
8/// # Examples
9///
10/// ```
11/// use ailloli_ui_core::ScrollAxes;
12///
13/// assert!(ScrollAxes::VERTICAL.vertical);
14/// assert!(!ScrollAxes::VERTICAL.horizontal);
15/// ```
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct ScrollAxes {
18 /// `true` permits horizontal offset and wheel deltas; `false` forces zero.
19 pub horizontal: bool,
20 /// `true` permits vertical offset and wheel deltas; `false` forces zero.
21 pub vertical: bool,
22}
23
24impl ScrollAxes {
25 /// Disables scrolling on both axes.
26 ///
27 /// # Examples
28 ///
29 /// ```
30 /// use ailloli_ui_core::ScrollAxes;
31 /// assert_eq!(ScrollAxes::NONE, ScrollAxes { horizontal: false, vertical: false });
32 /// ```
33 pub const NONE: Self = Self {
34 horizontal: false,
35 vertical: false,
36 };
37 /// Enables horizontal scrolling only.
38 ///
39 /// # Examples
40 ///
41 /// ```
42 /// use ailloli_ui_core::ScrollAxes;
43 /// assert!(ScrollAxes::HORIZONTAL.horizontal);
44 /// ```
45 pub const HORIZONTAL: Self = Self {
46 horizontal: true,
47 vertical: false,
48 };
49 /// Enables vertical scrolling only.
50 ///
51 /// # Examples
52 ///
53 /// ```
54 /// use ailloli_ui_core::ScrollAxes;
55 /// assert!(ScrollAxes::VERTICAL.vertical);
56 /// ```
57 pub const VERTICAL: Self = Self {
58 horizontal: false,
59 vertical: true,
60 };
61 /// Enables scrolling on both axes.
62 ///
63 /// # Examples
64 ///
65 /// ```
66 /// use ailloli_ui_core::ScrollAxes;
67 /// assert!(ScrollAxes::BOTH.horizontal && ScrollAxes::BOTH.vertical);
68 /// ```
69 pub const BOTH: Self = Self {
70 horizontal: true,
71 vertical: true,
72 };
73
74 /// Returns [`Self::HORIZONTAL`].
75 ///
76 /// # Examples
77 ///
78 /// ```
79 /// use ailloli_ui_core::ScrollAxes;
80 /// assert_eq!(ScrollAxes::horizontal(), ScrollAxes::HORIZONTAL);
81 /// ```
82 pub fn horizontal() -> Self {
83 Self::HORIZONTAL
84 }
85
86 /// Returns [`Self::VERTICAL`].
87 ///
88 /// # Examples
89 ///
90 /// ```
91 /// use ailloli_ui_core::ScrollAxes;
92 /// assert_eq!(ScrollAxes::vertical(), ScrollAxes::VERTICAL);
93 /// ```
94 pub fn vertical() -> Self {
95 Self::VERTICAL
96 }
97
98 /// Returns [`Self::BOTH`].
99 ///
100 /// # Examples
101 ///
102 /// ```
103 /// use ailloli_ui_core::ScrollAxes;
104 /// assert_eq!(ScrollAxes::both(), ScrollAxes::BOTH);
105 /// ```
106 pub fn both() -> Self {
107 Self::BOTH
108 }
109
110 /// Preserves enabled components and replaces disabled components with zero.
111 ///
112 /// # Examples
113 ///
114 /// ```
115 /// use ailloli_ui_core::{Offset, ScrollAxes};
116 /// assert_eq!(ScrollAxes::VERTICAL.filter_offset(Offset::new(3.0, 4.0)), Offset::new(0.0, 4.0));
117 /// ```
118 pub fn filter_offset(self, offset: Offset) -> Offset {
119 Offset::new(
120 if self.horizontal { offset.x } else { 0.0 },
121 if self.vertical { offset.y } else { 0.0 },
122 )
123 }
124}
125
126/// Viewport/content sizes used to clamp a scroll state.
127///
128/// # Examples
129///
130/// ```
131/// use ailloli_ui_core::{ScrollMetrics, Size};
132///
133/// let metrics = ScrollMetrics::new(Size::new(100.0, 80.0), Size::new(320.0, 240.0));
134/// assert_eq!(metrics.viewport.w, 100.0);
135/// ```
136#[derive(Debug, Clone, Copy, PartialEq)]
137pub struct ScrollMetrics {
138 /// Visible viewport size in logical pixels.
139 pub viewport: Size,
140 /// Full scrollable content size in logical pixels.
141 pub content: Size,
142}
143
144impl ScrollMetrics {
145 /// Creates metrics without normalizing either size.
146 ///
147 /// # Examples
148 ///
149 /// ```
150 /// use ailloli_ui_core::{ScrollMetrics, Size};
151 /// let metrics = ScrollMetrics::new(Size::new(10.0, 20.0), Size::new(30.0, 40.0));
152 /// assert_eq!(metrics.content.h, 40.0);
153 /// ```
154 pub fn new(viewport: Size, content: Size) -> Self {
155 Self { viewport, content }
156 }
157
158 /// Returns the greatest non-negative logical offset on each axis.
159 ///
160 /// Content smaller than its viewport produces zero. A non-finite
161 /// subtraction also produces zero for that axis.
162 ///
163 /// # Examples
164 ///
165 /// ```
166 /// use ailloli_ui_core::{Offset, ScrollMetrics, Size};
167 /// let metrics = ScrollMetrics::new(Size::new(100.0, 80.0), Size::new(320.0, 240.0));
168 /// assert_eq!(metrics.max_offset(), Offset::new(220.0, 160.0));
169 /// ```
170 pub fn max_offset(self) -> Offset {
171 Offset::new(
172 max_axis_offset(self.content.w, self.viewport.w),
173 max_axis_offset(self.content.h, self.viewport.h),
174 )
175 }
176}
177
178/// Main axis of one scrollbar.
179///
180/// The axis identifies both the content offset being controlled and the
181/// direction in which the thumb travels.
182///
183/// # Examples
184///
185/// ```
186/// use ailloli_ui_core::ScrollbarAxis;
187/// assert_ne!(ScrollbarAxis::Horizontal, ScrollbarAxis::Vertical);
188/// ```
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
190pub enum ScrollbarAxis {
191 /// Controls the horizontal content offset; the thumb travels left/right.
192 Horizontal,
193 /// Controls the vertical content offset; the thumb travels up/down.
194 Vertical,
195}
196
197impl ScrollbarAxis {
198 /// Selects this axis from a two-dimensional offset.
199 fn offset(self, offset: Offset) -> f32 {
200 match self {
201 Self::Horizontal => offset.x,
202 Self::Vertical => offset.y,
203 }
204 }
205
206 /// Selects this axis from a two-dimensional size.
207 fn extent(self, size: Size) -> f32 {
208 match self {
209 Self::Horizontal => size.w,
210 Self::Vertical => size.h,
211 }
212 }
213
214 /// Selects the point coordinate that follows the thumb travel direction.
215 fn coordinate(self, point: Point) -> f32 {
216 match self {
217 Self::Horizontal => point.x,
218 Self::Vertical => point.y,
219 }
220 }
221}
222
223/// Interactive part under a pointer within one scrollbar hit region.
224///
225/// # Examples
226///
227/// ```
228/// use ailloli_ui_core::ScrollbarPart;
229/// assert_ne!(ScrollbarPart::TrackBefore, ScrollbarPart::TrackAfter);
230/// ```
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
232pub enum ScrollbarPart {
233 /// Draggable proportional thumb.
234 Thumb,
235 /// Track area before the thumb along the scrollbar's main axis.
236 TrackBefore,
237 /// Track area after the thumb along the scrollbar's main axis.
238 TrackAfter,
239}
240
241/// Inputs used to resolve one proportional scrollbar.
242///
243/// Geometry is expressed in logical pixels. [`Self::resolve`] rejects
244/// non-finite values, non-positive extents, and axes without meaningful
245/// overflow instead of constructing partially valid rectangles.
246///
247/// # Examples
248///
249/// ```
250/// use ailloli_ui_core::{
251/// Rect, ScrollMetrics, ScrollState, ScrollbarAxis, ScrollbarGeometrySpec, Size,
252/// };
253/// let geometry = ScrollbarGeometrySpec::new(
254/// ScrollbarAxis::Vertical,
255/// Rect::new(0.0, 0.0, 100.0, 80.0),
256/// ScrollMetrics::new(Size::new(100.0, 80.0), Size::new(100.0, 240.0)),
257/// ScrollState::new(),
258/// )
259/// .resolve()
260/// .expect("vertical overflow");
261/// assert_eq!(geometry.axis, ScrollbarAxis::Vertical);
262/// ```
263#[derive(Debug, Clone, Copy, PartialEq)]
264pub struct ScrollbarGeometrySpec {
265 /// Controlled axis.
266 pub axis: ScrollbarAxis,
267 /// Complete viewport bounds in the coordinate space used for hit testing.
268 pub bounds: Rect,
269 /// Visible and full content extents.
270 pub metrics: ScrollMetrics,
271 /// Current logical content offset.
272 pub state: ScrollState,
273 /// Painted cross-axis thickness.
274 pub thickness: f32,
275 /// Minimum painted thumb length, capped to the resolved track.
276 pub min_thumb_len: f32,
277 /// Distance between the track and viewport edges.
278 pub inset: f32,
279 /// Main-axis space reserved at the trailing end, normally for another bar.
280 pub end_reserve: f32,
281 /// Minimum cross-axis thickness of the pointer hit region.
282 pub hit_thickness: f32,
283}
284
285impl ScrollbarGeometrySpec {
286 /// Default pointer hit thickness used by framework scrollbars.
287 pub const DEFAULT_HIT_THICKNESS: f32 = 16.0;
288
289 /// Creates a specification with the framework's neutral geometry defaults.
290 ///
291 /// The painted bar is six pixels thick, inset by three pixels, with a
292 /// 24-pixel minimum thumb and a 16-pixel pointer target.
293 ///
294 /// # Examples
295 ///
296 /// ```
297 /// use ailloli_ui_core::{Rect, ScrollMetrics, ScrollState, ScrollbarAxis, ScrollbarGeometrySpec, Size};
298 /// let spec = ScrollbarGeometrySpec::new(
299 /// ScrollbarAxis::Horizontal,
300 /// Rect::new(0.0, 0.0, 120.0, 60.0),
301 /// ScrollMetrics::new(Size::new(120.0, 60.0), Size::new(360.0, 60.0)),
302 /// ScrollState::new(),
303 /// );
304 /// assert_eq!(spec.hit_thickness, 16.0);
305 /// ```
306 pub const fn new(
307 axis: ScrollbarAxis,
308 bounds: Rect,
309 metrics: ScrollMetrics,
310 state: ScrollState,
311 ) -> Self {
312 Self {
313 axis,
314 bounds,
315 metrics,
316 state,
317 thickness: 6.0,
318 min_thumb_len: 24.0,
319 inset: 3.0,
320 end_reserve: 0.0,
321 hit_thickness: Self::DEFAULT_HIT_THICKNESS,
322 }
323 }
324
325 /// Replaces painted geometry while retaining the standard hit thickness.
326 ///
327 /// # Examples
328 ///
329 /// ```
330 /// use ailloli_ui_core::{Rect, ScrollMetrics, ScrollState, ScrollbarAxis, ScrollbarGeometrySpec, Size};
331 /// let spec = ScrollbarGeometrySpec::new(
332 /// ScrollbarAxis::Vertical,
333 /// Rect::new(0.0, 0.0, 80.0, 100.0),
334 /// ScrollMetrics::new(Size::new(80.0, 100.0), Size::new(80.0, 300.0)),
335 /// ScrollState::new(),
336 /// ).with_paint_metrics(4.0, 18.0, 2.0);
337 /// assert_eq!((spec.thickness, spec.min_thumb_len, spec.inset), (4.0, 18.0, 2.0));
338 /// ```
339 pub fn with_paint_metrics(mut self, thickness: f32, min_thumb_len: f32, inset: f32) -> Self {
340 self.thickness = thickness;
341 self.min_thumb_len = min_thumb_len;
342 self.inset = inset;
343 self
344 }
345
346 /// Reserves main-axis space at the trailing end of the track.
347 ///
348 /// # Examples
349 ///
350 /// ```
351 /// use ailloli_ui_core::{Rect, ScrollMetrics, ScrollState, ScrollbarAxis, ScrollbarGeometrySpec, Size};
352 /// let spec = ScrollbarGeometrySpec::new(
353 /// ScrollbarAxis::Vertical,
354 /// Rect::new(0.0, 0.0, 80.0, 100.0),
355 /// ScrollMetrics::new(Size::new(80.0, 100.0), Size::new(80.0, 300.0)),
356 /// ScrollState::new(),
357 /// ).with_end_reserve(10.0);
358 /// assert_eq!(spec.end_reserve, 10.0);
359 /// ```
360 pub fn with_end_reserve(mut self, end_reserve: f32) -> Self {
361 self.end_reserve = end_reserve;
362 self
363 }
364
365 /// Replaces the minimum pointer-target thickness.
366 ///
367 /// Values smaller than the painted thickness are promoted to the painted
368 /// thickness during resolution.
369 ///
370 /// # Examples
371 ///
372 /// ```
373 /// use ailloli_ui_core::{Rect, ScrollMetrics, ScrollState, ScrollbarAxis, ScrollbarGeometrySpec, Size};
374 /// let spec = ScrollbarGeometrySpec::new(
375 /// ScrollbarAxis::Vertical,
376 /// Rect::new(0.0, 0.0, 80.0, 100.0),
377 /// ScrollMetrics::new(Size::new(80.0, 100.0), Size::new(80.0, 300.0)),
378 /// ScrollState::new(),
379 /// ).with_hit_thickness(20.0);
380 /// assert_eq!(spec.hit_thickness, 20.0);
381 /// ```
382 pub fn with_hit_thickness(mut self, hit_thickness: f32) -> Self {
383 self.hit_thickness = hit_thickness;
384 self
385 }
386
387 /// Resolves proportional track, thumb, and pointer geometry.
388 ///
389 /// Overflow of at most half a logical pixel is treated as absent. This
390 /// avoids flickering bars for floating-point layout noise.
391 ///
392 /// # Examples
393 ///
394 /// ```
395 /// use ailloli_ui_core::{Point, Rect, ScrollMetrics, ScrollState, ScrollbarAxis, ScrollbarGeometrySpec, ScrollbarPart, Size};
396 /// let geometry = ScrollbarGeometrySpec::new(
397 /// ScrollbarAxis::Vertical,
398 /// Rect::new(0.0, 0.0, 100.0, 100.0),
399 /// ScrollMetrics::new(Size::new(100.0, 100.0), Size::new(100.0, 400.0)),
400 /// ScrollState::new(),
401 /// ).resolve().unwrap();
402 /// let center = Point::new(geometry.thumb.x + geometry.thumb.w * 0.5, geometry.thumb.y + geometry.thumb.h * 0.5);
403 /// assert_eq!(geometry.hit_test(center), Some(ScrollbarPart::Thumb));
404 /// ```
405 pub fn resolve(self) -> Option<ScrollbarGeometry> {
406 if !rect_is_finite_positive(self.bounds)
407 || !size_is_finite_positive(self.metrics.viewport)
408 || !size_is_finite_positive(self.metrics.content)
409 || !positive_finite(self.thickness)
410 || !non_negative_finite(self.min_thumb_len)
411 || !non_negative_finite(self.inset)
412 || !non_negative_finite(self.end_reserve)
413 || !positive_finite(self.hit_thickness)
414 {
415 return None;
416 }
417
418 let viewport_extent = self.axis.extent(self.metrics.viewport);
419 let content_extent = self.axis.extent(self.metrics.content);
420 let max_offset = (content_extent - viewport_extent).max(0.0);
421 if max_offset <= 0.5 {
422 return None;
423 }
424
425 let bounds_extent = match self.axis {
426 ScrollbarAxis::Horizontal => self.bounds.w,
427 ScrollbarAxis::Vertical => self.bounds.h,
428 };
429 let track_len = bounds_extent - self.inset * 2.0 - self.end_reserve;
430 if !positive_finite(track_len) || track_len <= self.thickness {
431 return None;
432 }
433
434 let ratio = (viewport_extent / content_extent).clamp(0.0, 1.0);
435 let thumb_len = (track_len * ratio)
436 .max(self.min_thumb_len.min(track_len))
437 .min(track_len);
438 if !positive_finite(thumb_len) {
439 return None;
440 }
441 let travel = (track_len - thumb_len).max(0.0);
442 let current_offset =
443 finite_or_zero(self.axis.offset(self.state.offset)).clamp(0.0, max_offset);
444 let progress = if max_offset > 0.0 {
445 current_offset / max_offset
446 } else {
447 0.0
448 };
449
450 let (track, thumb) = match self.axis {
451 ScrollbarAxis::Horizontal => {
452 let track = Rect::new(
453 self.bounds.x + self.inset,
454 self.bounds.bottom() - self.inset - self.thickness,
455 track_len,
456 self.thickness,
457 );
458 let thumb = Rect::new(track.x + travel * progress, track.y, thumb_len, track.h);
459 (track, thumb)
460 }
461 ScrollbarAxis::Vertical => {
462 let track = Rect::new(
463 self.bounds.right() - self.inset - self.thickness,
464 self.bounds.y + self.inset,
465 self.thickness,
466 track_len,
467 );
468 let thumb = Rect::new(track.x, track.y + travel * progress, track.w, thumb_len);
469 (track, thumb)
470 }
471 };
472
473 let requested_hit_cross = self.hit_thickness.max(self.thickness);
474 let hit_track = match self.axis {
475 ScrollbarAxis::Horizontal => {
476 let hit_cross = requested_hit_cross.min(self.bounds.h);
477 let desired_y = track.y + (track.h - hit_cross) * 0.5;
478 let y = desired_y.clamp(self.bounds.y, self.bounds.bottom() - hit_cross);
479 Rect::new(track.x, y, track.w, hit_cross)
480 }
481 ScrollbarAxis::Vertical => {
482 let hit_cross = requested_hit_cross.min(self.bounds.w);
483 let desired_x = track.x + (track.w - hit_cross) * 0.5;
484 let x = desired_x.clamp(self.bounds.x, self.bounds.right() - hit_cross);
485 Rect::new(x, track.y, hit_cross, track.h)
486 }
487 };
488
489 Some(ScrollbarGeometry {
490 axis: self.axis,
491 track,
492 thumb,
493 hit_track,
494 viewport_extent,
495 max_offset,
496 })
497 }
498}
499
500/// Resolved proportional scrollbar geometry in one coordinate space.
501///
502/// `track` and `thumb` are paint rectangles. `hit_track` may be wider than the
503/// paint while remaining clipped to the original viewport.
504///
505/// # Examples
506///
507/// ```
508/// use ailloli_ui_core::{Rect, ScrollMetrics, ScrollState, ScrollbarAxis, ScrollbarGeometrySpec, Size};
509/// let geometry = ScrollbarGeometrySpec::new(
510/// ScrollbarAxis::Horizontal,
511/// Rect::new(0.0, 0.0, 100.0, 60.0),
512/// ScrollMetrics::new(Size::new(100.0, 60.0), Size::new(300.0, 60.0)),
513/// ScrollState::new(),
514/// ).resolve().unwrap();
515/// assert!(geometry.hit_track.h >= geometry.track.h);
516/// ```
517#[derive(Debug, Clone, Copy, PartialEq)]
518pub struct ScrollbarGeometry {
519 /// Controlled axis.
520 pub axis: ScrollbarAxis,
521 /// Painted track bounds.
522 pub track: Rect,
523 /// Painted proportional thumb bounds.
524 pub thumb: Rect,
525 /// Pointer target containing the track and expanded cross-axis area.
526 pub hit_track: Rect,
527 /// Visible content extent along [`Self::axis`].
528 pub viewport_extent: f32,
529 /// Greatest valid content offset along [`Self::axis`].
530 pub max_offset: f32,
531}
532
533impl ScrollbarGeometry {
534 /// Returns the thumb or the track side containing `point`.
535 ///
536 /// # Examples
537 ///
538 /// ```
539 /// use ailloli_ui_core::{Point, Rect, ScrollMetrics, ScrollState, ScrollbarAxis, ScrollbarGeometrySpec, ScrollbarPart, Size};
540 /// let geometry = ScrollbarGeometrySpec::new(
541 /// ScrollbarAxis::Vertical,
542 /// Rect::new(0.0, 0.0, 100.0, 100.0),
543 /// ScrollMetrics::new(Size::new(100.0, 100.0), Size::new(100.0, 300.0)),
544 /// ScrollState::new(),
545 /// ).resolve().unwrap();
546 /// assert_eq!(geometry.hit_test(Point::new(geometry.track.x, geometry.track.bottom())), Some(ScrollbarPart::TrackAfter));
547 /// ```
548 pub fn hit_test(self, point: Point) -> Option<ScrollbarPart> {
549 if !self.hit_track.contains(point.x, point.y) {
550 return None;
551 }
552 let coordinate = self.axis.coordinate(point);
553 let thumb_start = axis_rect_start(self.axis, self.thumb);
554 let thumb_end = axis_rect_end(self.axis, self.thumb);
555 if coordinate < thumb_start {
556 Some(ScrollbarPart::TrackBefore)
557 } else if coordinate > thumb_end {
558 Some(ScrollbarPart::TrackAfter)
559 } else {
560 Some(ScrollbarPart::Thumb)
561 }
562 }
563
564 /// Computes a one-viewport page target for a track hit.
565 ///
566 /// Thumb hits leave the current offset unchanged. All results are finite
567 /// and clamped to `0.0..=max_offset`.
568 ///
569 /// # Examples
570 ///
571 /// ```
572 /// use ailloli_ui_core::{Rect, ScrollMetrics, ScrollState, ScrollbarAxis, ScrollbarGeometrySpec, ScrollbarPart, Size};
573 /// let geometry = ScrollbarGeometrySpec::new(
574 /// ScrollbarAxis::Vertical,
575 /// Rect::new(0.0, 0.0, 100.0, 100.0),
576 /// ScrollMetrics::new(Size::new(100.0, 100.0), Size::new(100.0, 400.0)),
577 /// ScrollState::new(),
578 /// ).resolve().unwrap();
579 /// assert_eq!(geometry.page_target(40.0, ScrollbarPart::TrackAfter), 140.0);
580 /// ```
581 pub fn page_target(self, current_offset: f32, part: ScrollbarPart) -> f32 {
582 let current = finite_or_zero(current_offset).clamp(0.0, self.max_offset);
583 let target = match part {
584 ScrollbarPart::Thumb => current,
585 ScrollbarPart::TrackBefore => current - self.viewport_extent,
586 ScrollbarPart::TrackAfter => current + self.viewport_extent,
587 };
588 finite_or_zero(target).clamp(0.0, self.max_offset)
589 }
590
591 /// Maps a track position to an offset with the thumb centered on the point.
592 ///
593 /// The result is clamped at both track ends. Callers normally use this for
594 /// a press outside the thumb; thumb presses should retain their exact grab
595 /// offset through [`Self::begin_drag`] instead.
596 ///
597 /// # Examples
598 ///
599 /// ```
600 /// use ailloli_ui_core::{Point, Rect, ScrollMetrics, ScrollState, ScrollbarAxis, ScrollbarGeometrySpec, Size};
601 /// let geometry = ScrollbarGeometrySpec::new(
602 /// ScrollbarAxis::Vertical,
603 /// Rect::new(0.0, 0.0, 80.0, 100.0),
604 /// ScrollMetrics::new(Size::new(80.0, 100.0), Size::new(80.0, 400.0)),
605 /// ScrollState::new(),
606 /// ).resolve().unwrap();
607 /// let point = Point::new(geometry.track.x, geometry.track.y + geometry.track.h * 0.5);
608 /// assert!(geometry.track_target(point) > 0.0);
609 /// ```
610 pub fn track_target(self, point: Point) -> f32 {
611 let thumb_len = axis_rect_extent(self.axis, self.thumb);
612 ScrollbarDrag {
613 axis: self.axis,
614 grab_offset: thumb_len * 0.5,
615 }
616 .target_offset(point, self)
617 }
618
619 /// Begins a drag only when `point` hits the thumb.
620 ///
621 /// The retained logical grab offset keeps the exact pressed point attached
622 /// to the pointer instead of recentering the thumb at drag start.
623 ///
624 /// # Examples
625 ///
626 /// ```
627 /// use ailloli_ui_core::{Point, Rect, ScrollMetrics, ScrollState, ScrollbarAxis, ScrollbarGeometrySpec, Size};
628 /// let geometry = ScrollbarGeometrySpec::new(
629 /// ScrollbarAxis::Horizontal,
630 /// Rect::new(0.0, 0.0, 100.0, 60.0),
631 /// ScrollMetrics::new(Size::new(100.0, 60.0), Size::new(300.0, 60.0)),
632 /// ScrollState::new(),
633 /// ).resolve().unwrap();
634 /// let center = Point::new(geometry.thumb.x + geometry.thumb.w * 0.5, geometry.thumb.y);
635 /// assert!(geometry.begin_drag(center).is_some());
636 /// ```
637 pub fn begin_drag(self, point: Point) -> Option<ScrollbarDrag> {
638 if self.hit_test(point) != Some(ScrollbarPart::Thumb) {
639 return None;
640 }
641 let thumb_start = axis_rect_start(self.axis, self.thumb);
642 let thumb_len = axis_rect_extent(self.axis, self.thumb);
643 let travel = axis_rect_extent(self.axis, self.track) - thumb_len;
644 if !positive_finite(thumb_len) || !positive_finite(travel) {
645 return None;
646 }
647 let grab_offset = (self.axis.coordinate(point) - thumb_start).clamp(0.0, thumb_len);
648 Some(ScrollbarDrag {
649 axis: self.axis,
650 grab_offset,
651 })
652 }
653}
654
655/// Pointer-relative state retained while dragging one scrollbar thumb.
656///
657/// # Examples
658///
659/// ```
660/// use ailloli_ui_core::ScrollbarAxis;
661/// assert_eq!(format!("{:?}", ScrollbarAxis::Horizontal), "Horizontal");
662/// ```
663#[derive(Debug, Clone, Copy, PartialEq)]
664pub struct ScrollbarDrag {
665 /// Axis captured when the drag began.
666 pub axis: ScrollbarAxis,
667 /// Logical-pixel distance from the thumb start to the pressed point.
668 pub grab_offset: f32,
669}
670
671impl ScrollbarDrag {
672 /// Maps the current pointer position to a clamped logical content offset.
673 ///
674 /// A geometry for another axis or a track with no thumb travel returns
675 /// zero. Movement outside the track is accepted and clamped.
676 ///
677 /// # Examples
678 ///
679 /// ```
680 /// use ailloli_ui_core::{Point, Rect, ScrollMetrics, ScrollState, ScrollbarAxis, ScrollbarGeometrySpec, Size};
681 /// let geometry = ScrollbarGeometrySpec::new(
682 /// ScrollbarAxis::Horizontal,
683 /// Rect::new(0.0, 0.0, 100.0, 60.0),
684 /// ScrollMetrics::new(Size::new(100.0, 60.0), Size::new(300.0, 60.0)),
685 /// ScrollState::new(),
686 /// ).resolve().unwrap();
687 /// let drag = geometry.begin_drag(Point::new(geometry.thumb.x, geometry.thumb.y)).unwrap();
688 /// assert_eq!(drag.target_offset(Point::new(10_000.0, 0.0), geometry), geometry.max_offset);
689 /// ```
690 pub fn target_offset(self, point: Point, geometry: ScrollbarGeometry) -> f32 {
691 if self.axis != geometry.axis {
692 return 0.0;
693 }
694 let track_start = axis_rect_start(self.axis, geometry.track);
695 let track_len = axis_rect_extent(self.axis, geometry.track);
696 let thumb_len = axis_rect_extent(self.axis, geometry.thumb);
697 let travel = track_len - thumb_len;
698 if !positive_finite(travel) || !positive_finite(geometry.max_offset) {
699 return 0.0;
700 }
701 let grab_offset = finite_or_zero(self.grab_offset).clamp(0.0, thumb_len);
702 let thumb_start = self.axis.coordinate(point) - grab_offset;
703 let progress = ((thumb_start - track_start) / travel).clamp(0.0, 1.0);
704 finite_or_zero(progress * geometry.max_offset).clamp(0.0, geometry.max_offset)
705 }
706}
707
708/// Returns whether all rectangle components are finite and both extents positive.
709fn rect_is_finite_positive(rect: Rect) -> bool {
710 rect.x.is_finite() && rect.y.is_finite() && positive_finite(rect.w) && positive_finite(rect.h)
711}
712
713/// Returns whether both size components are finite and positive.
714fn size_is_finite_positive(size: Size) -> bool {
715 positive_finite(size.w) && positive_finite(size.h)
716}
717
718/// Returns whether `value` is finite and strictly positive.
719fn positive_finite(value: f32) -> bool {
720 value.is_finite() && value > 0.0
721}
722
723/// Returns whether `value` is finite and non-negative.
724fn non_negative_finite(value: f32) -> bool {
725 value.is_finite() && value >= 0.0
726}
727
728/// Maps non-finite values to zero while preserving finite values verbatim.
729fn finite_or_zero(value: f32) -> f32 {
730 if value.is_finite() {
731 value
732 } else {
733 0.0
734 }
735}
736
737/// Returns a rectangle's leading coordinate along `axis`.
738fn axis_rect_start(axis: ScrollbarAxis, rect: Rect) -> f32 {
739 match axis {
740 ScrollbarAxis::Horizontal => rect.x,
741 ScrollbarAxis::Vertical => rect.y,
742 }
743}
744
745/// Returns a rectangle's trailing coordinate along `axis`.
746fn axis_rect_end(axis: ScrollbarAxis, rect: Rect) -> f32 {
747 match axis {
748 ScrollbarAxis::Horizontal => rect.right(),
749 ScrollbarAxis::Vertical => rect.bottom(),
750 }
751}
752
753/// Returns a rectangle's extent along `axis`.
754fn axis_rect_extent(axis: ScrollbarAxis, rect: Rect) -> f32 {
755 match axis {
756 ScrollbarAxis::Horizontal => rect.w,
757 ScrollbarAxis::Vertical => rect.h,
758 }
759}
760
761/// Computes one finite, non-negative `content - viewport` extent.
762fn max_axis_offset(content: f32, viewport: f32) -> f32 {
763 let value = content - viewport;
764 if value.is_finite() {
765 value.max(0.0)
766 } else {
767 0.0
768 }
769}
770
771/// Clamps one finite offset to `0.0..=max`, mapping non-finite values to zero.
772fn clamp_axis(value: f32, max: f32) -> f32 {
773 if value.is_finite() {
774 value.clamp(0.0, max.max(0.0))
775 } else {
776 0.0
777 }
778}
779
780/// Persistent logical scroll offset. Positive values move the viewport into content.
781///
782/// # Examples
783///
784/// ```
785/// use ailloli_ui_core::{Offset, ScrollState};
786/// assert_eq!(ScrollState::with_offset(Offset::new(4.0, 5.0)).offset.x, 4.0);
787/// ```
788#[derive(Debug, Clone, Copy, Default, PartialEq)]
789pub struct ScrollState {
790 /// Logical content-space offset from the content origin.
791 pub offset: Offset,
792}
793
794impl ScrollState {
795 /// Creates a state at the content origin.
796 ///
797 /// # Examples
798 ///
799 /// ```
800 /// use ailloli_ui_core::{Offset, ScrollState};
801 /// assert_eq!(ScrollState::new().offset, Offset::default());
802 /// ```
803 pub fn new() -> Self {
804 Self::default()
805 }
806
807 /// Creates a state whose components are at least zero.
808 ///
809 /// This does not know the content bounds. Use [`Self::clamp_to`] once
810 /// [`ScrollMetrics`] are available. Negative values and NaN become zero;
811 /// positive infinity remains infinite until a bounded operation such as
812 /// [`Self::clamp_to`] maps it to zero.
813 ///
814 /// # Examples
815 ///
816 /// ```
817 /// use ailloli_ui_core::{Offset, ScrollState};
818 /// assert_eq!(ScrollState::with_offset(Offset::new(-1.0, 2.0)).offset, Offset::new(0.0, 2.0));
819 /// ```
820 pub fn with_offset(offset: Offset) -> Self {
821 Self {
822 offset: Offset::new(offset.x.max(0.0), offset.y.max(0.0)),
823 }
824 }
825
826 /// Clamps the current state to the enabled axes and content bounds.
827 ///
828 /// Disabled axes are reset to zero rather than preserving a latent offset.
829 ///
830 /// # Examples
831 ///
832 /// ```
833 /// use ailloli_ui_core::{Offset, ScrollAxes, ScrollMetrics, ScrollState, Size};
834 /// let metrics = ScrollMetrics::new(Size::new(10.0, 10.0), Size::new(20.0, 20.0));
835 /// let out = ScrollState::with_offset(Offset::new(50.0, 5.0)).clamp_to(metrics, ScrollAxes::BOTH);
836 /// assert_eq!(out.after, Offset::new(10.0, 5.0));
837 /// ```
838 pub fn clamp_to(self, metrics: ScrollMetrics, axes: ScrollAxes) -> ScrollOutcome {
839 let max = axes.filter_offset(metrics.max_offset());
840 let target = Offset::new(
841 clamp_axis(self.offset.x, max.x),
842 clamp_axis(self.offset.y, max.y),
843 );
844 ScrollOutcome::from_offsets(self.offset, target, max)
845 }
846
847 /// Adds a logical-pixel delta, filters disabled axes, and clamps the result.
848 ///
849 /// Positive deltas move the viewport farther into the content.
850 ///
851 /// # Examples
852 ///
853 /// ```
854 /// use ailloli_ui_core::{Offset, ScrollAxes, ScrollMetrics, ScrollState, Size};
855 /// let metrics = ScrollMetrics::new(Size::new(10.0, 10.0), Size::new(30.0, 30.0));
856 /// assert_eq!(ScrollState::new().scroll_by(Offset::new(4.0, 5.0), metrics, ScrollAxes::BOTH).after, Offset::new(4.0, 5.0));
857 /// ```
858 pub fn scroll_by(
859 self,
860 delta: Offset,
861 metrics: ScrollMetrics,
862 axes: ScrollAxes,
863 ) -> ScrollOutcome {
864 let delta = axes.filter_offset(delta);
865 self.scroll_to(
866 Offset::new(self.offset.x + delta.x, self.offset.y + delta.y),
867 metrics,
868 axes,
869 )
870 }
871
872 /// Moves directly to a logical content offset and clamps it to valid bounds.
873 ///
874 /// Disabled axes and non-finite target components become zero.
875 ///
876 /// # Examples
877 ///
878 /// ```
879 /// use ailloli_ui_core::{Offset, ScrollAxes, ScrollMetrics, ScrollState, Size};
880 /// let metrics = ScrollMetrics::new(Size::new(10.0, 10.0), Size::new(30.0, 30.0));
881 /// assert_eq!(ScrollState::new().scroll_to(Offset::new(8.0, 9.0), metrics, ScrollAxes::BOTH).after, Offset::new(8.0, 9.0));
882 /// ```
883 pub fn scroll_to(
884 self,
885 target: Offset,
886 metrics: ScrollMetrics,
887 axes: ScrollAxes,
888 ) -> ScrollOutcome {
889 let max = axes.filter_offset(metrics.max_offset());
890 let target = axes.filter_offset(target);
891 let target = Offset::new(clamp_axis(target.x, max.x), clamp_axis(target.y, max.y));
892 ScrollOutcome::from_offsets(self.offset, target, max)
893 }
894
895 /// Moves the minimum distance needed to expose `rect` in the viewport.
896 ///
897 /// `rect` uses content coordinates in logical pixels. Already visible axes
898 /// remain unchanged. If a rectangle is larger than the viewport, the first
899 /// violated edge chosen by the algorithm is aligned before final clamping.
900 ///
901 /// # Examples
902 ///
903 /// ```
904 /// use ailloli_ui_core::{Offset, Rect, ScrollAxes, ScrollMetrics, ScrollState, Size};
905 /// let metrics = ScrollMetrics::new(Size::new(100.0, 100.0), Size::new(300.0, 300.0));
906 /// let out = ScrollState::new().reveal_rect(Rect::new(120.0, 0.0, 20.0, 20.0), metrics, ScrollAxes::BOTH);
907 /// assert_eq!(out.after, Offset::new(40.0, 0.0));
908 /// ```
909 pub fn reveal_rect(
910 self,
911 rect: Rect,
912 metrics: ScrollMetrics,
913 axes: ScrollAxes,
914 ) -> ScrollOutcome {
915 let mut target = self.offset;
916
917 if axes.horizontal {
918 if rect.x < target.x {
919 target.x = rect.x;
920 } else if rect.right() > target.x + metrics.viewport.w {
921 target.x = rect.right() - metrics.viewport.w;
922 }
923 }
924
925 if axes.vertical {
926 if rect.y < target.y {
927 target.y = rect.y;
928 } else if rect.bottom() > target.y + metrics.viewport.h {
929 target.y = rect.bottom() - metrics.viewport.h;
930 }
931 }
932
933 self.scroll_to(target, metrics, axes)
934 }
935}
936
937/// Wheel normalization and per-widget scroll tuning.
938///
939/// # Examples
940///
941/// ```
942/// use ailloli_ui_core::{ScrollAxes, ScrollBehavior};
943/// let behavior = ScrollBehavior::new(ScrollAxes::VERTICAL);
944/// assert_eq!(behavior.line_px, ScrollBehavior::DEFAULT_LINE_PX);
945/// ```
946#[derive(Debug, Clone, Copy, PartialEq)]
947pub struct ScrollBehavior {
948 /// Axes that receive normalized wheel motion.
949 pub axes: ScrollAxes,
950 /// Logical pixels represented by one platform line unit.
951 pub line_px: f32,
952}
953
954impl ScrollBehavior {
955 /// Default logical distance of one wheel line: 48 pixels.
956 ///
957 /// # Examples
958 ///
959 /// ```
960 /// use ailloli_ui_core::ScrollBehavior;
961 /// assert_eq!(ScrollBehavior::DEFAULT_LINE_PX, 48.0);
962 /// ```
963 pub const DEFAULT_LINE_PX: f32 = 48.0;
964
965 /// Creates behavior for `axes` using [`Self::DEFAULT_LINE_PX`].
966 ///
967 /// # Examples
968 ///
969 /// ```
970 /// use ailloli_ui_core::{ScrollAxes, ScrollBehavior};
971 /// assert_eq!(ScrollBehavior::new(ScrollAxes::BOTH).axes, ScrollAxes::BOTH);
972 /// ```
973 pub fn new(axes: ScrollAxes) -> Self {
974 Self {
975 axes,
976 line_px: Self::DEFAULT_LINE_PX,
977 }
978 }
979
980 /// Sets the line distance, clamped to at least one logical pixel.
981 ///
982 /// NaN and negative infinity become `1.0` through floating-point `max`
983 /// semantics; positive infinity remains infinite.
984 ///
985 /// # Examples
986 ///
987 /// ```
988 /// use ailloli_ui_core::{ScrollAxes, ScrollBehavior};
989 /// assert_eq!(ScrollBehavior::new(ScrollAxes::BOTH).with_line_px(0.0).line_px, 1.0);
990 /// ```
991 pub fn with_line_px(mut self, line_px: f32) -> Self {
992 self.line_px = line_px.max(1.0);
993 self
994 }
995
996 /// Converts a platform wheel delta to a filtered logical content offset.
997 ///
998 /// Line deltas are scaled by [`Self::line_px`]; pixel deltas are already in
999 /// logical pixels. Both forms are negated so a positive wheel delta moves
1000 /// the viewed content toward its origin.
1001 ///
1002 /// # Examples
1003 ///
1004 /// ```
1005 /// use ailloli_ui_core::{Offset, ScrollAxes, ScrollBehavior};
1006 /// use ailloli_ui_core::event::WheelDelta;
1007 /// let delta = ScrollBehavior::new(ScrollAxes::VERTICAL).wheel_delta(WheelDelta::LineDelta { x: 1.0, y: -2.0 });
1008 /// assert_eq!(delta, Offset::new(0.0, 96.0));
1009 /// ```
1010 pub fn wheel_delta(self, delta: WheelDelta) -> Offset {
1011 self.wheel_delta_with_modifiers(delta, Modifiers::default())
1012 }
1013
1014 /// Converts wheel input while applying the framework's Shift-axis policy.
1015 ///
1016 /// Line values use [`Self::line_px`] and pixel values are already logical.
1017 /// Native horizontal movement is preserved. When Shift is held and the
1018 /// horizontal axis is enabled, normalized vertical movement is added to X
1019 /// and removed from Y. Vertical-only surfaces therefore keep scrolling
1020 /// vertically under Shift. Non-finite components become zero.
1021 ///
1022 /// The provider's `precise` flag is intentionally absent: line-versus-pixel
1023 /// already determines scaling, while precision remains event metadata and
1024 /// does not introduce acceleration.
1025 ///
1026 /// # Examples
1027 ///
1028 /// ```
1029 /// use ailloli_ui_core::{event::{Modifiers, WheelDelta}, Offset, ScrollAxes, ScrollBehavior};
1030 /// let modifiers = Modifiers { shift: true, ..Modifiers::default() };
1031 /// let delta = ScrollBehavior::new(ScrollAxes::BOTH).with_line_px(10.0)
1032 /// .wheel_delta_with_modifiers(WheelDelta::LineDelta { x: -1.0, y: -2.0 }, modifiers);
1033 /// assert_eq!(delta, Offset::new(30.0, 0.0));
1034 /// ```
1035 pub fn wheel_delta_with_modifiers(self, delta: WheelDelta, modifiers: Modifiers) -> Offset {
1036 let mut offset = match delta {
1037 WheelDelta::LineDelta { x, y } => Offset::new(-x * self.line_px, -y * self.line_px),
1038 WheelDelta::PixelDelta { x, y } => Offset::new(-x, -y),
1039 };
1040 offset.x = finite_or_zero(offset.x);
1041 offset.y = finite_or_zero(offset.y);
1042 if modifiers.shift && self.axes.horizontal {
1043 offset.x = finite_or_zero(offset.x + offset.y);
1044 offset.y = 0.0;
1045 }
1046 self.axes.filter_offset(offset)
1047 }
1048}
1049
1050/// Result of applying scroll logic.
1051///
1052/// # Examples
1053///
1054/// ```
1055/// use ailloli_ui_core::{Offset, ScrollAxes, ScrollMetrics, ScrollState, Size};
1056/// let metrics = ScrollMetrics::new(Size::new(10.0, 10.0), Size::new(20.0, 20.0));
1057/// let outcome = ScrollState::new().scroll_to(Offset::new(5.0, 0.0), metrics, ScrollAxes::BOTH);
1058/// assert!(outcome.changed);
1059/// ```
1060#[derive(Debug, Clone, Copy, PartialEq)]
1061pub struct ScrollOutcome {
1062 /// Offset before the operation; it may itself be out of bounds.
1063 pub before: Offset,
1064 /// Clamped offset after the operation.
1065 pub after: Offset,
1066 /// Maximum valid offset for the enabled axes.
1067 pub max_offset: Offset,
1068 /// Whether `before` and `after` differ by at least `0.001` on either axis.
1069 pub changed: bool,
1070}
1071
1072impl ScrollOutcome {
1073 /// Constructs an outcome and derives its tolerance-based change flag.
1074 fn from_offsets(before: Offset, after: Offset, max_offset: Offset) -> Self {
1075 Self {
1076 before,
1077 after,
1078 max_offset,
1079 changed: !same_offset(before, after),
1080 }
1081 }
1082
1083 /// Converts the final offset into the next persistent scroll state.
1084 ///
1085 /// # Examples
1086 ///
1087 /// ```
1088 /// use ailloli_ui_core::{Offset, ScrollAxes, ScrollMetrics, ScrollState, Size};
1089 /// let metrics = ScrollMetrics::new(Size::new(10.0, 10.0), Size::new(20.0, 20.0));
1090 /// let state = ScrollState::new().scroll_to(Offset::new(5.0, 0.0), metrics, ScrollAxes::BOTH).state();
1091 /// assert_eq!(state.offset, Offset::new(5.0, 0.0));
1092 /// ```
1093 pub fn state(self) -> ScrollState {
1094 ScrollState { offset: self.after }
1095 }
1096}
1097
1098/// Compares logical offsets with a strict per-axis tolerance of `0.001` pixels.
1099fn same_offset(a: Offset, b: Offset) -> bool {
1100 (a.x - b.x).abs() < 0.001 && (a.y - b.y).abs() < 0.001
1101}
1102
1103#[cfg(test)]
1104mod tests {
1105 //! Covers bounds, disabled axes, wheel signs, and minimum-distance reveal.
1106
1107 use super::*;
1108
1109 fn metrics() -> ScrollMetrics {
1110 ScrollMetrics::new(Size::new(100.0, 80.0), Size::new(320.0, 240.0))
1111 }
1112
1113 #[test]
1114 fn scroll_by_clamps_to_content_bounds() {
1115 let out =
1116 ScrollState::new().scroll_by(Offset::new(500.0, 500.0), metrics(), ScrollAxes::BOTH);
1117
1118 assert_eq!(out.after, Offset::new(220.0, 160.0));
1119 assert!(out.changed);
1120 }
1121
1122 #[test]
1123 fn vertical_axis_ignores_horizontal_delta() {
1124 let out =
1125 ScrollState::new().scroll_by(Offset::new(50.0, 40.0), metrics(), ScrollAxes::VERTICAL);
1126
1127 assert_eq!(out.after, Offset::new(0.0, 40.0));
1128 }
1129
1130 #[test]
1131 fn content_smaller_than_viewport_forces_zero_offset() {
1132 let small = ScrollMetrics::new(Size::new(100.0, 100.0), Size::new(50.0, 40.0));
1133 let out =
1134 ScrollState::with_offset(Offset::new(20.0, 20.0)).clamp_to(small, ScrollAxes::BOTH);
1135
1136 assert_eq!(out.after, Offset::new(0.0, 0.0));
1137 assert!(out.changed);
1138 }
1139
1140 #[test]
1141 fn wheel_delta_uses_platform_sign_and_line_height() {
1142 let behavior = ScrollBehavior::new(ScrollAxes::BOTH).with_line_px(12.0);
1143
1144 assert_eq!(
1145 behavior.wheel_delta(WheelDelta::PixelDelta { x: -4.0, y: 6.0 }),
1146 Offset::new(4.0, -6.0)
1147 );
1148 assert_eq!(
1149 behavior.wheel_delta(WheelDelta::LineDelta { x: 1.0, y: -2.0 }),
1150 Offset::new(-12.0, 24.0)
1151 );
1152 }
1153
1154 #[test]
1155 fn wheel_delta_with_shift_preserves_native_x_and_remaps_y() {
1156 let behavior = ScrollBehavior::new(ScrollAxes::BOTH).with_line_px(10.0);
1157 let modifiers = Modifiers {
1158 shift: true,
1159 ..Modifiers::default()
1160 };
1161
1162 assert_eq!(
1163 behavior
1164 .wheel_delta_with_modifiers(WheelDelta::LineDelta { x: -1.0, y: -2.0 }, modifiers,),
1165 Offset::new(30.0, 0.0)
1166 );
1167 assert_eq!(
1168 ScrollBehavior::new(ScrollAxes::VERTICAL)
1169 .wheel_delta_with_modifiers(WheelDelta::PixelDelta { x: 0.0, y: -6.0 }, modifiers,),
1170 Offset::new(0.0, 6.0)
1171 );
1172 }
1173
1174 #[test]
1175 fn wheel_delta_sanitizes_non_finite_components() {
1176 let behavior = ScrollBehavior::new(ScrollAxes::BOTH);
1177 assert_eq!(
1178 behavior.wheel_delta_with_modifiers(
1179 WheelDelta::PixelDelta {
1180 x: f32::NAN,
1181 y: f32::INFINITY,
1182 },
1183 Modifiers::default(),
1184 ),
1185 Offset::default()
1186 );
1187 }
1188
1189 #[test]
1190 fn scrollbar_geometry_pages_and_drags_on_both_axes() {
1191 let metrics = ScrollMetrics::new(Size::new(100.0, 80.0), Size::new(300.0, 240.0));
1192 for axis in [ScrollbarAxis::Horizontal, ScrollbarAxis::Vertical] {
1193 let geometry = ScrollbarGeometrySpec::new(
1194 axis,
1195 Rect::new(0.0, 0.0, 100.0, 80.0),
1196 metrics,
1197 ScrollState::new(),
1198 )
1199 .resolve()
1200 .unwrap();
1201 assert!(geometry.hit_track.w >= geometry.track.w);
1202 assert!(geometry.hit_track.h >= geometry.track.h);
1203 assert!(geometry.page_target(0.0, ScrollbarPart::TrackAfter) > 0.0);
1204
1205 let thumb_center = Point::new(
1206 geometry.thumb.x + geometry.thumb.w * 0.5,
1207 geometry.thumb.y + geometry.thumb.h * 0.5,
1208 );
1209 let drag = geometry.begin_drag(thumb_center).unwrap();
1210 assert_eq!(
1211 drag.target_offset(Point::new(10_000.0, 10_000.0), geometry),
1212 geometry.max_offset
1213 );
1214 }
1215 }
1216
1217 #[test]
1218 fn scrollbar_track_pages_one_viewport_before_and_after_the_thumb() {
1219 let geometry = ScrollbarGeometrySpec::new(
1220 ScrollbarAxis::Vertical,
1221 Rect::new(0.0, 0.0, 100.0, 80.0),
1222 ScrollMetrics::new(Size::new(100.0, 80.0), Size::new(100.0, 240.0)),
1223 ScrollState::with_offset(Offset::new(0.0, 100.0)),
1224 )
1225 .resolve()
1226 .unwrap();
1227
1228 assert_eq!(
1229 geometry.page_target(100.0, ScrollbarPart::TrackBefore),
1230 20.0
1231 );
1232 assert_eq!(
1233 geometry.page_target(100.0, ScrollbarPart::TrackAfter),
1234 160.0
1235 );
1236 }
1237
1238 #[test]
1239 fn scrollbar_geometry_rejects_missing_overflow_and_invalid_values() {
1240 let bounds = Rect::new(0.0, 0.0, 100.0, 80.0);
1241 let no_overflow = ScrollMetrics::new(Size::new(100.0, 80.0), Size::new(100.0, 80.0));
1242 assert!(ScrollbarGeometrySpec::new(
1243 ScrollbarAxis::Vertical,
1244 bounds,
1245 no_overflow,
1246 ScrollState::new(),
1247 )
1248 .resolve()
1249 .is_none());
1250
1251 let overflow = ScrollMetrics::new(Size::new(100.0, 80.0), Size::new(100.0, 160.0));
1252 let mut invalid = ScrollbarGeometrySpec::new(
1253 ScrollbarAxis::Vertical,
1254 bounds,
1255 overflow,
1256 ScrollState::new(),
1257 );
1258 invalid.thickness = f32::NAN;
1259 assert!(invalid.resolve().is_none());
1260 }
1261
1262 #[test]
1263 fn scrollbar_geometry_tracks_origin_middle_maximum_and_minimum_thumb() {
1264 let bounds = Rect::new(0.0, 0.0, 120.0, 100.0);
1265 let metrics = ScrollMetrics::new(Size::new(120.0, 100.0), Size::new(120.0, 2_000.0));
1266 let at_origin = ScrollbarGeometrySpec::new(
1267 ScrollbarAxis::Vertical,
1268 bounds,
1269 metrics,
1270 ScrollState::new(),
1271 )
1272 .resolve()
1273 .unwrap();
1274 assert_eq!(at_origin.thumb.y, at_origin.track.y);
1275 assert_eq!(at_origin.thumb.h, 24.0);
1276 assert_eq!(at_origin.hit_track.w, 16.0);
1277
1278 let middle = ScrollbarGeometrySpec::new(
1279 ScrollbarAxis::Vertical,
1280 bounds,
1281 metrics,
1282 ScrollState::with_offset(Offset::new(0.0, 950.0)),
1283 )
1284 .resolve()
1285 .unwrap();
1286 assert!((middle.thumb.y - (middle.track.y + 35.0)).abs() < 0.001);
1287
1288 let maximum = ScrollbarGeometrySpec::new(
1289 ScrollbarAxis::Vertical,
1290 bounds,
1291 metrics,
1292 ScrollState::with_offset(Offset::new(0.0, 1_900.0)),
1293 )
1294 .resolve()
1295 .unwrap();
1296 assert_eq!(maximum.thumb.bottom(), maximum.track.bottom());
1297 }
1298
1299 #[test]
1300 fn scrollbar_drag_retains_the_exact_grab_and_clamps_it_after_resize() {
1301 let original_metrics = ScrollMetrics::new(Size::new(180.0, 80.0), Size::new(360.0, 240.0));
1302 let original = ScrollbarGeometrySpec::new(
1303 ScrollbarAxis::Horizontal,
1304 Rect::new(0.0, 0.0, 180.0, 80.0),
1305 original_metrics,
1306 ScrollState::new(),
1307 )
1308 .resolve()
1309 .unwrap();
1310 let press = Point::new(
1311 original.thumb.x + original.thumb.w * 0.75,
1312 original.thumb.y + original.thumb.h * 0.5,
1313 );
1314 let drag = original.begin_drag(press).unwrap();
1315 assert!((drag.grab_offset - original.thumb.w * 0.75).abs() < 0.001);
1316
1317 let resized = ScrollbarGeometrySpec::new(
1318 ScrollbarAxis::Horizontal,
1319 Rect::new(0.0, 0.0, 180.0, 80.0),
1320 ScrollMetrics::new(Size::new(180.0, 80.0), Size::new(1_800.0, 240.0)),
1321 ScrollState::new(),
1322 )
1323 .resolve()
1324 .unwrap();
1325 assert!(drag.grab_offset > resized.thumb.w);
1326 let travel = resized.track.w - resized.thumb.w;
1327 let pointer = Point::new(resized.track.x + travel * 0.4 + resized.thumb.w, press.y);
1328 let target = drag.target_offset(pointer, resized);
1329 assert!((target - resized.max_offset * 0.4).abs() < 0.001);
1330 }
1331
1332 #[test]
1333 fn scrollbar_track_target_centers_the_thumb_when_not_clamped() {
1334 let bounds = Rect::new(0.0, 0.0, 80.0, 100.0);
1335 let metrics = ScrollMetrics::new(Size::new(80.0, 100.0), Size::new(80.0, 400.0));
1336 let origin = ScrollbarGeometrySpec::new(
1337 ScrollbarAxis::Vertical,
1338 bounds,
1339 metrics,
1340 ScrollState::new(),
1341 )
1342 .resolve()
1343 .unwrap();
1344 let point = Point::new(origin.track.x, origin.track.y + origin.track.h * 0.5);
1345 let target = origin.track_target(point);
1346 let moved = ScrollbarGeometrySpec::new(
1347 ScrollbarAxis::Vertical,
1348 bounds,
1349 metrics,
1350 ScrollState::with_offset(Offset::new(0.0, target)),
1351 )
1352 .resolve()
1353 .unwrap();
1354
1355 assert!((moved.thumb.y + moved.thumb.h * 0.5 - point.y).abs() < 0.001);
1356 }
1357
1358 #[test]
1359 fn scrollbar_geometry_keeps_a_full_track_thumb_but_disables_drag() {
1360 let geometry = ScrollbarGeometrySpec::new(
1361 ScrollbarAxis::Vertical,
1362 Rect::new(0.0, 0.0, 30.0, 30.0),
1363 ScrollMetrics::new(Size::new(30.0, 30.0), Size::new(30.0, 31.0)),
1364 ScrollState::new(),
1365 )
1366 .resolve()
1367 .unwrap();
1368 assert_eq!(geometry.thumb, geometry.track);
1369 assert!(geometry
1370 .begin_drag(Point::new(geometry.thumb.x, geometry.thumb.y))
1371 .is_none());
1372 }
1373
1374 #[test]
1375 fn scrollbar_geometry_uses_half_pixel_overflow_threshold() {
1376 let bounds = Rect::new(0.0, 0.0, 100.0, 80.0);
1377 let viewport = Size::new(100.0, 80.0);
1378 let at_threshold = ScrollMetrics::new(viewport, Size::new(100.0, 80.5));
1379 let beyond_threshold = ScrollMetrics::new(viewport, Size::new(100.0, 80.51));
1380
1381 assert!(ScrollbarGeometrySpec::new(
1382 ScrollbarAxis::Vertical,
1383 bounds,
1384 at_threshold,
1385 ScrollState::new(),
1386 )
1387 .resolve()
1388 .is_none());
1389 assert!(ScrollbarGeometrySpec::new(
1390 ScrollbarAxis::Vertical,
1391 bounds,
1392 beyond_threshold,
1393 ScrollState::new(),
1394 )
1395 .resolve()
1396 .is_some());
1397 }
1398
1399 #[test]
1400 fn reveal_rect_moves_minimum_distance_to_show_target() {
1401 let state = ScrollState::with_offset(Offset::new(20.0, 10.0));
1402 let out = state.reveal_rect(
1403 Rect::new(130.0, 150.0, 20.0, 20.0),
1404 metrics(),
1405 ScrollAxes::BOTH,
1406 );
1407
1408 assert_eq!(out.after, Offset::new(50.0, 90.0));
1409 }
1410}