1#[derive(Clone, Copy, Debug, PartialEq)]
14pub struct ThumbGeometry {
15 pub length: f32,
17 pub offset: f32,
20}
21
22impl ThumbGeometry {
23 pub fn end(self) -> f32 {
25 self.offset + self.length
26 }
27
28 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#[derive(Clone, Copy, Debug, PartialEq)]
45pub struct ThumbBounds {
46 minimum: f32,
47 maximum: f32,
48}
49
50impl ThumbBounds {
51 pub const FULL: Self = Self {
54 minimum: 0.0,
55 maximum: 1.0,
56 };
57
58 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 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 pub fn minimum(self) -> f32 {
88 self.minimum
89 }
90
91 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
103pub 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
131pub 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");
213 assert_eq!(roomy.length, 0.5);
214 }
215
216 #[test]
217 fn bounds_cannot_describe_an_empty_or_inverted_range() {
218 let inverted = ThumbBounds::new(0.8, 0.2);
219 assert_eq!(inverted.minimum(), 0.2);
220 assert_eq!(inverted.maximum(), 0.8);
221
222 let unmeasurable = ThumbBounds::new(f32::NAN, f32::NAN);
223 assert_eq!(unmeasurable, ThumbBounds::FULL);
224
225 assert_eq!(ThumbBounds::at_least(24.0, 0.0), ThumbBounds::FULL);
226 assert_eq!(ThumbBounds::at_least(f32::NAN, 240.0), ThumbBounds::FULL);
227 }
228
229 #[test]
230 fn dragging_the_thumb_across_its_travel_scrolls_the_whole_content() {
231 let geometry = thumb_geometry(400.0, 100.0, 0.0, ThumbBounds::FULL).expect("scrollable");
232 assert_eq!(
234 content_delta_for_thumb_drag(150.0, 200.0, geometry, 300.0),
235 300.0
236 );
237 assert_eq!(
238 content_delta_for_thumb_drag(-75.0, 200.0, geometry, 300.0),
239 -150.0
240 );
241 }
242
243 #[test]
244 fn a_thumb_with_nowhere_to_go_does_not_scroll() {
245 let full = ThumbGeometry {
246 length: 1.0,
247 offset: 0.0,
248 };
249 assert_eq!(content_delta_for_thumb_drag(40.0, 200.0, full, 300.0), 0.0);
250
251 let geometry = thumb_geometry(400.0, 100.0, 0.0, ThumbBounds::FULL).expect("scrollable");
252 assert_eq!(
253 content_delta_for_thumb_drag(40.0, 200.0, geometry, 0.0),
254 0.0
255 );
256 assert_eq!(
257 content_delta_for_thumb_drag(f32::NAN, 200.0, geometry, 300.0),
258 0.0
259 );
260 }
261}