1use std::ops::Range;
36
37pub const SUBCELL: usize = 8;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum CellFill {
46 Empty,
48 Full,
50 Partial {
52 start: u8,
54 len: u8,
56 },
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum HitTest {
64 Thumb,
66 Track,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub struct ScrollMetrics {
79 content_len: usize,
80 viewport_len: usize,
81 offset: usize,
82 track_cells: usize,
83 track_len: usize,
84 thumb_len: usize,
85 thumb_start: usize,
86}
87
88impl ScrollMetrics {
89 pub fn from_lengths(lengths: crate::ScrollLengths, offset: usize, track_cells: u16) -> Self {
91 Self::new(lengths, offset, track_cells)
92 }
93
94 pub fn new(lengths: crate::ScrollLengths, offset: usize, track_cells: u16) -> Self {
101 let track_cells = track_cells as usize;
102 let track_len = track_cells.saturating_mul(SUBCELL);
103
104 if track_len == 0 {
105 return Self {
106 content_len: lengths.content_len,
107 viewport_len: lengths.viewport_len,
108 offset,
109 track_cells,
110 track_len,
111 thumb_len: 0,
112 thumb_start: 0,
113 };
114 }
115
116 let content_len = lengths.content_len.max(1);
117 let viewport_len = lengths.viewport_len.min(content_len).max(1);
118 let max_offset = content_len.saturating_sub(viewport_len);
119 let offset = offset.min(max_offset);
120
121 let thumb_len = (track_len.saturating_mul(viewport_len) / content_len)
122 .max(SUBCELL)
123 .min(track_len);
124 let thumb_travel = track_len.saturating_sub(thumb_len);
125 let thumb_start = thumb_travel
126 .saturating_mul(offset)
127 .checked_div(max_offset)
128 .unwrap_or(0);
129
130 Self {
131 content_len,
132 viewport_len,
133 offset,
134 track_cells,
135 track_len,
136 thumb_len,
137 thumb_start,
138 }
139 }
140
141 pub const fn content_len(&self) -> usize {
143 self.content_len
144 }
145
146 pub const fn viewport_len(&self) -> usize {
148 self.viewport_len
149 }
150
151 pub const fn offset(&self) -> usize {
153 self.offset
154 }
155
156 pub const fn track_cells(&self) -> usize {
158 self.track_cells
159 }
160
161 pub const fn track_len(&self) -> usize {
163 self.track_len
164 }
165
166 pub const fn thumb_len(&self) -> usize {
168 self.thumb_len
169 }
170
171 pub const fn thumb_start(&self) -> usize {
173 self.thumb_start
174 }
175
176 pub const fn max_offset(&self) -> usize {
178 self.content_len.saturating_sub(self.viewport_len)
179 }
180
181 pub const fn thumb_travel(&self) -> usize {
183 self.track_len.saturating_sub(self.thumb_len)
184 }
185
186 pub const fn thumb_range(&self) -> Range<usize> {
188 self.thumb_start..self.thumb_start.saturating_add(self.thumb_len)
189 }
190
191 pub const fn hit_test(&self, position: usize) -> HitTest {
193 if position >= self.thumb_start
194 && position < self.thumb_start.saturating_add(self.thumb_len)
195 {
196 HitTest::Thumb
197 } else {
198 HitTest::Track
199 }
200 }
201
202 pub fn thumb_start_for_offset(&self, offset: usize) -> usize {
206 let max_offset = self.max_offset();
207 let offset = offset.min(max_offset);
208 self.thumb_travel()
209 .saturating_mul(offset)
210 .checked_div(max_offset)
211 .unwrap_or(0)
212 }
213
214 pub fn offset_for_thumb_start(&self, thumb_start: usize) -> usize {
218 let max_offset = self.max_offset();
219 let thumb_start = thumb_start.min(self.thumb_travel());
220 max_offset
221 .saturating_mul(thumb_start)
222 .checked_div(self.thumb_travel())
223 .unwrap_or(0)
224 }
225
226 pub fn cell_fill(&self, cell_index: usize) -> CellFill {
231 if self.thumb_len == 0 {
232 return CellFill::Empty;
233 }
234
235 let cell_start = cell_index.saturating_mul(SUBCELL);
236 let cell_end = cell_start.saturating_add(SUBCELL);
237
238 let thumb_end = self.thumb_start.saturating_add(self.thumb_len);
239 let start = self.thumb_start.max(cell_start);
240 let end = thumb_end.min(cell_end);
241
242 if end <= start {
243 return CellFill::Empty;
244 }
245
246 let len = end.saturating_sub(start).min(SUBCELL) as u8;
247 let start = start.saturating_sub(cell_start).min(SUBCELL) as u8;
248
249 if len as usize >= SUBCELL {
250 CellFill::Full
251 } else {
252 CellFill::Partial { start, len }
253 }
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260 #[test]
261 fn fills_track_when_no_scroll() {
262 let metrics = ScrollMetrics::new(
263 crate::ScrollLengths {
264 content_len: 10,
265 viewport_len: 10,
266 },
267 0,
268 4,
269 );
270 assert_eq!(metrics.thumb_len(), 32);
271 assert_eq!(metrics.thumb_start(), 0);
272 }
273
274 #[test]
275 fn clamps_offset_to_max() {
276 let metrics = ScrollMetrics::new(
277 crate::ScrollLengths {
278 content_len: 100,
279 viewport_len: 10,
280 },
281 200,
282 4,
283 );
284 assert_eq!(metrics.offset(), 90);
285 assert_eq!(metrics.thumb_start(), metrics.thumb_travel());
286 }
287
288 #[test]
289 fn reports_partial_cell_fills() {
290 let metrics = ScrollMetrics::new(
291 crate::ScrollLengths {
292 content_len: 10,
293 viewport_len: 3,
294 },
295 1,
296 4,
297 );
298 assert_eq!(metrics.cell_fill(0), CellFill::Partial { start: 3, len: 5 });
299 assert_eq!(metrics.cell_fill(1), CellFill::Partial { start: 0, len: 4 });
300 assert_eq!(metrics.cell_fill(2), CellFill::Empty);
301 }
302
303 #[test]
304 fn distinguishes_thumb_vs_track_hits() {
305 let metrics = ScrollMetrics::new(
306 crate::ScrollLengths {
307 content_len: 10,
308 viewport_len: 3,
309 },
310 1,
311 4,
312 );
313 assert_eq!(metrics.hit_test(0), HitTest::Track);
314 assert_eq!(metrics.hit_test(4), HitTest::Thumb);
315 assert_eq!(metrics.hit_test(12), HitTest::Track);
316 }
317
318 #[test]
319 fn stays_scale_invariant_for_logical_units() {
320 let track_cells = 10;
321 let base = ScrollMetrics::new(
322 crate::ScrollLengths {
323 content_len: 200,
324 viewport_len: 20,
325 },
326 10,
327 track_cells,
328 );
329 let scaled = ScrollMetrics::new(
330 crate::ScrollLengths {
331 content_len: 200 * SUBCELL,
332 viewport_len: 20 * SUBCELL,
333 },
334 10 * SUBCELL,
335 track_cells,
336 );
337 assert_eq!(base.thumb_len(), scaled.thumb_len());
338 assert_eq!(base.thumb_start(), scaled.thumb_start());
339 }
340
341 #[test]
342 fn yields_empty_thumb_when_track_len_zero() {
343 let lengths = crate::ScrollLengths {
344 content_len: 10,
345 viewport_len: 4,
346 };
347 let metrics = ScrollMetrics::new(lengths, 0, 0);
348 assert_eq!(metrics.track_len(), 0);
349 assert_eq!(metrics.thumb_len(), 0);
350 assert_eq!(metrics.cell_fill(0), CellFill::Empty);
351 }
352
353 #[test]
354 fn reports_full_cell_when_thumb_covers_track() {
355 let lengths = crate::ScrollLengths {
356 content_len: 8,
357 viewport_len: 8,
358 };
359 let metrics = ScrollMetrics::new(lengths, 0, 1);
360 assert_eq!(metrics.thumb_len(), SUBCELL);
361 assert_eq!(metrics.cell_fill(0), CellFill::Full);
362 }
363
364 #[test]
365 fn treats_zero_lengths_as_one() {
366 let lengths = crate::ScrollLengths {
367 content_len: 0,
368 viewport_len: 0,
369 };
370 let metrics = ScrollMetrics::new(lengths, 0, 1);
371 assert_eq!(metrics.content_len(), 1);
372 assert_eq!(metrics.viewport_len(), 1);
373 assert_eq!(metrics.thumb_len(), SUBCELL);
374 }
375
376 #[test]
377 fn thumb_start_for_offset_returns_zero_when_no_scroll() {
378 let lengths = crate::ScrollLengths {
379 content_len: 10,
380 viewport_len: 10,
381 };
382 let metrics = ScrollMetrics::new(lengths, 0, 4);
383 assert_eq!(metrics.thumb_start_for_offset(5), 0);
384 }
385
386 #[test]
387 fn offset_for_thumb_start_returns_zero_when_no_scroll() {
388 let lengths = crate::ScrollLengths {
389 content_len: 10,
390 viewport_len: 10,
391 };
392 let metrics = ScrollMetrics::new(lengths, 0, 4);
393 assert_eq!(metrics.offset_for_thumb_start(5), 0);
394 }
395
396 #[test]
397 fn conversions_return_zero_when_thumb_cannot_travel() {
398 let lengths = crate::ScrollLengths {
399 content_len: 10,
400 viewport_len: 3,
401 };
402 let metrics = ScrollMetrics::new(lengths, 0, 1);
403 assert_eq!(metrics.thumb_travel(), 0);
404 assert_eq!(metrics.thumb_start_for_offset(5), 0);
405 assert_eq!(metrics.offset_for_thumb_start(5), 0);
406 }
407
408 #[test]
409 fn hit_test_returns_track_before_thumb_start() {
410 let lengths = crate::ScrollLengths {
411 content_len: 10,
412 viewport_len: 3,
413 };
414 let metrics = ScrollMetrics::new(lengths, 1, 4);
415 assert_eq!(
416 metrics.hit_test(metrics.thumb_start().saturating_sub(1)),
417 HitTest::Track
418 );
419 }
420
421 #[test]
422 fn hit_test_returns_track_at_thumb_end() {
423 let lengths = crate::ScrollLengths {
424 content_len: 10,
425 viewport_len: 3,
426 };
427 let metrics = ScrollMetrics::new(lengths, 1, 4);
428 let thumb_end = metrics.thumb_start().saturating_add(metrics.thumb_len());
429 assert_eq!(metrics.hit_test(thumb_end), HitTest::Track);
430 }
431
432 #[test]
433 fn reports_empty_cell_fill_when_thumb_len_zero() {
434 let lengths = crate::ScrollLengths {
435 content_len: 10,
436 viewport_len: 4,
437 };
438 let metrics = ScrollMetrics::new(lengths, 0, 0);
439 assert_eq!(metrics.cell_fill(0), CellFill::Empty);
440 }
441
442 #[test]
443 fn thumb_range_matches_start_and_len() {
444 let lengths = crate::ScrollLengths {
445 content_len: 10,
446 viewport_len: 3,
447 };
448 let metrics = ScrollMetrics::new(lengths, 1, 4);
449 assert_eq!(
450 metrics.thumb_range(),
451 metrics.thumb_start()..metrics.thumb_start().saturating_add(metrics.thumb_len())
452 );
453 }
454
455 #[test]
456 fn clamps_thumb_start_for_offset() {
457 let lengths = crate::ScrollLengths {
458 content_len: 100,
459 viewport_len: 10,
460 };
461 let metrics = ScrollMetrics::new(lengths, 0, 4);
462 let max_offset = metrics.max_offset();
463 assert_eq!(
464 metrics.thumb_start_for_offset(max_offset.saturating_add(10)),
465 metrics.thumb_travel()
466 );
467 }
468
469 #[test]
470 fn clamps_offset_for_thumb_start() {
471 let lengths = crate::ScrollLengths {
472 content_len: 100,
473 viewport_len: 10,
474 };
475 let metrics = ScrollMetrics::new(lengths, 0, 4);
476 let max_offset = metrics.max_offset();
477 assert_eq!(
478 metrics.offset_for_thumb_start(metrics.thumb_travel().saturating_add(10)),
479 max_offset
480 );
481 }
482}