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)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn content_that_fits_shows_no_thumb() {
158        assert_eq!(thumb_geometry(100.0, 100.0, 0.0, ThumbBounds::FULL), None);
159        assert_eq!(thumb_geometry(80.0, 100.0, 0.0, ThumbBounds::FULL), None);
160        assert_eq!(thumb_geometry(500.0, 0.0, 0.0, ThumbBounds::FULL), None);
161    }
162
163    #[test]
164    fn a_measurement_that_is_not_a_number_shows_no_thumb() {
165        assert_eq!(
166            thumb_geometry(f32::NAN, 100.0, 0.0, ThumbBounds::FULL),
167            None
168        );
169        assert_eq!(
170            thumb_geometry(400.0, f32::INFINITY, 0.0, ThumbBounds::FULL),
171            None
172        );
173        assert_eq!(
174            thumb_geometry(400.0, 100.0, f32::NAN, ThumbBounds::FULL),
175            None
176        );
177    }
178
179    #[test]
180    fn the_thumb_is_the_share_of_content_on_screen_and_travels_with_it() {
181        let top = thumb_geometry(400.0, 100.0, 0.0, ThumbBounds::FULL).expect("scrollable");
182        assert_eq!(top.length, 0.25);
183        assert_eq!(top.offset, 0.0);
184        assert_eq!(top.progress(), 0.0);
185
186        let bottom = thumb_geometry(400.0, 100.0, 300.0, ThumbBounds::FULL).expect("scrollable");
187        assert_eq!(bottom.offset, 0.75);
188        assert_eq!(bottom.end(), 1.0);
189        assert_eq!(bottom.progress(), 1.0);
190
191        let middle = thumb_geometry(400.0, 100.0, 150.0, ThumbBounds::FULL).expect("scrollable");
192        assert_eq!(middle.offset, 0.375);
193        assert_eq!(middle.progress(), 0.5);
194    }
195
196    #[test]
197    fn scrolling_past_either_end_stays_on_the_track() {
198        let before = thumb_geometry(400.0, 100.0, -50.0, ThumbBounds::FULL).expect("scrollable");
199        assert_eq!(before.offset, 0.0);
200        let after = thumb_geometry(400.0, 100.0, 900.0, ThumbBounds::FULL).expect("scrollable");
201        assert_eq!(after.end(), 1.0);
202    }
203
204    #[test]
205    fn a_very_long_list_still_shows_a_grabbable_thumb() {
206        let bounds = ThumbBounds::at_least(24.0, 240.0);
207        let geometry = thumb_geometry(100_000.0, 240.0, 0.0, bounds).expect("scrollable");
208        assert_eq!(geometry.length, 0.1);
209
210        let roomy = thumb_geometry(480.0, 240.0, 0.0, bounds).expect("scrollable");
211        assert_eq!(roomy.length, 0.5);
212    }
213
214    #[test]
215    fn bounds_cannot_describe_an_empty_or_inverted_range() {
216        let inverted = ThumbBounds::new(0.8, 0.2);
217        assert_eq!(inverted.minimum(), 0.2);
218        assert_eq!(inverted.maximum(), 0.8);
219
220        let unmeasurable = ThumbBounds::new(f32::NAN, f32::NAN);
221        assert_eq!(unmeasurable, ThumbBounds::FULL);
222
223        assert_eq!(ThumbBounds::at_least(24.0, 0.0), ThumbBounds::FULL);
224        assert_eq!(ThumbBounds::at_least(f32::NAN, 240.0), ThumbBounds::FULL);
225    }
226
227    #[test]
228    fn dragging_the_thumb_across_its_travel_scrolls_the_whole_content() {
229        let geometry = thumb_geometry(400.0, 100.0, 0.0, ThumbBounds::FULL).expect("scrollable");
230        assert_eq!(
231            content_delta_for_thumb_drag(150.0, 200.0, geometry, 300.0),
232            300.0
233        );
234        assert_eq!(
235            content_delta_for_thumb_drag(-75.0, 200.0, geometry, 300.0),
236            -150.0
237        );
238    }
239
240    #[test]
241    fn a_thumb_with_nowhere_to_go_does_not_scroll() {
242        let full = ThumbGeometry {
243            length: 1.0,
244            offset: 0.0,
245        };
246        assert_eq!(content_delta_for_thumb_drag(40.0, 200.0, full, 300.0), 0.0);
247
248        let geometry = thumb_geometry(400.0, 100.0, 0.0, ThumbBounds::FULL).expect("scrollable");
249        assert_eq!(
250            content_delta_for_thumb_drag(40.0, 200.0, geometry, 0.0),
251            0.0
252        );
253        assert_eq!(
254            content_delta_for_thumb_drag(f32::NAN, 200.0, geometry, 300.0),
255            0.0
256        );
257    }
258}