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}