Skip to main content

cranpose_ui/
scrollbar.rs

1//! Where a scrollbar's thumb sits and how long it is.
2//!
3//! Pure arithmetic over three lengths — how much content there is, how much of
4//! it is on screen, how far it has travelled — shared by every indicator that
5//! reports a scroll position: the rectangular [`Scrollbar`](crate::widgets::Scrollbar)
6//! and the curved indicator a round watch draws.
7//!
8//! What differs between them is only how short and how long the thumb is
9//! allowed to get, which is why [`ThumbBounds`] is a parameter rather than a
10//! constant here.
11
12/// Thumb length and position, both as fractions of the track.
13#[derive(Clone, Copy, Debug, PartialEq)]
14pub struct ThumbGeometry {
15    /// The thumb's share of the track, in `0..=1`.
16    pub length: f32,
17    /// The thumb's leading edge: `0.0` at the start of the track, and
18    /// `1.0 - length` once the content is scrolled to the end.
19    pub offset: f32,
20}
21
22impl ThumbGeometry {
23    /// The thumb's trailing edge, as a fraction of the track.
24    pub fn end(self) -> f32 {
25        self.offset + self.length
26    }
27
28    /// How far through its travel the thumb is, in `0..=1`. `0.0` when the
29    /// thumb fills the track and cannot travel at all.
30    pub fn progress(self) -> f32 {
31        let travel = 1.0 - self.length;
32        if travel <= 0.0 {
33            0.0
34        } else {
35            (self.offset / travel).clamp(0.0, 1.0)
36        }
37    }
38}
39
40/// How short and how long a thumb may get, as fractions of the track.
41///
42/// A very long list would otherwise produce a thumb too small to see or to
43/// grab; a platform states its own floor rather than inheriting someone else's.
44#[derive(Clone, Copy, Debug, PartialEq)]
45pub struct ThumbBounds {
46    minimum: f32,
47    maximum: f32,
48}
49
50impl ThumbBounds {
51    /// A thumb free to be any length, which is what a caller that enforces a
52    /// minimum in pixels rather than fractions wants.
53    pub const FULL: Self = Self {
54        minimum: 0.0,
55        maximum: 1.0,
56    };
57
58    /// Bounds clamped into `0..=1` and ordered, so a caller cannot describe an
59    /// empty range.
60    pub fn new(minimum: f32, maximum: f32) -> Self {
61        let minimum = if minimum.is_finite() {
62            minimum.clamp(0.0, 1.0)
63        } else {
64            0.0
65        };
66        let maximum = if maximum.is_finite() {
67            maximum.clamp(0.0, 1.0)
68        } else {
69            1.0
70        };
71        Self {
72            minimum: minimum.min(maximum),
73            maximum: maximum.max(minimum),
74        }
75    }
76
77    /// The bounds that keep a thumb at least `extent` long on a track of
78    /// `track` — the pixel-shaped way to say it.
79    pub fn at_least(extent: f32, track: f32) -> Self {
80        if !(extent.is_finite() && track.is_finite()) || track <= 0.0 || extent <= 0.0 {
81            return Self::FULL;
82        }
83        Self::new(extent / track, 1.0)
84    }
85
86    /// Shortest permitted thumb, as a fraction of the track.
87    pub fn minimum(self) -> f32 {
88        self.minimum
89    }
90
91    /// Longest permitted thumb, as a fraction of the track.
92    pub fn maximum(self) -> f32 {
93        self.maximum
94    }
95}
96
97impl Default for ThumbBounds {
98    fn default() -> Self {
99        Self::FULL
100    }
101}
102
103/// The thumb for a scroll position, or `None` when the content fits and there
104/// is nothing to indicate.
105///
106/// `content` and `viewport` are lengths in one unit and `scrolled` is how far
107/// the content has travelled in that same unit. Non-finite inputs and a
108/// viewport that has not been measured yet both answer `None` rather than a
109/// thumb drawn from a guess.
110pub fn thumb_geometry(
111    content: f32,
112    viewport: f32,
113    scrolled: f32,
114    bounds: ThumbBounds,
115) -> Option<ThumbGeometry> {
116    if !(content.is_finite() && viewport.is_finite() && scrolled.is_finite()) {
117        return None;
118    }
119    if viewport <= 0.0 || content <= viewport {
120        return None;
121    }
122    let length = (viewport / content).clamp(bounds.minimum(), bounds.maximum());
123    let travel = content - viewport;
124    let progress = (scrolled / travel).clamp(0.0, 1.0);
125    Some(ThumbGeometry {
126        length,
127        offset: progress * (1.0 - length),
128    })
129}
130
131/// How far the content moves when the thumb is dragged `delta` along a track of
132/// `track`, given the thumb it currently shows.
133///
134/// A thumb that fills its track cannot be dragged, and a scroll with no travel
135/// cannot follow one, so both answer zero rather than dividing by zero.
136pub fn content_delta_for_thumb_drag(
137    delta: f32,
138    track: f32,
139    geometry: ThumbGeometry,
140    max_offset: f32,
141) -> f32 {
142    if !(delta.is_finite() && track.is_finite() && max_offset.is_finite()) {
143        return 0.0;
144    }
145    let travel = track * (1.0 - geometry.length);
146    if travel <= 0.0 || max_offset <= 0.0 {
147        return 0.0;
148    }
149    delta / travel * max_offset
150}
151
152#[cfg(test)]
153#[path = "tests/scrollbar_tests.rs"]
154mod tests;