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, thumb_start) = if max_offset == 0 {
122 (track_len, 0)
123 } else {
124 let thumb_len = (track_len.saturating_mul(viewport_len) / content_len)
125 .max(SUBCELL)
126 .min(track_len);
127 let thumb_travel = track_len.saturating_sub(thumb_len);
128 let thumb_start = thumb_travel.saturating_mul(offset) / max_offset;
129 (thumb_len, thumb_start)
130 };
131
132 Self {
133 content_len,
134 viewport_len,
135 offset,
136 track_cells,
137 track_len,
138 thumb_len,
139 thumb_start,
140 }
141 }
142
143 pub const fn content_len(&self) -> usize {
145 self.content_len
146 }
147
148 pub const fn viewport_len(&self) -> usize {
150 self.viewport_len
151 }
152
153 pub const fn offset(&self) -> usize {
155 self.offset
156 }
157
158 pub const fn track_cells(&self) -> usize {
160 self.track_cells
161 }
162
163 pub const fn track_len(&self) -> usize {
165 self.track_len
166 }
167
168 pub const fn thumb_len(&self) -> usize {
170 self.thumb_len
171 }
172
173 pub const fn thumb_start(&self) -> usize {
175 self.thumb_start
176 }
177
178 pub const fn max_offset(&self) -> usize {
180 self.content_len.saturating_sub(self.viewport_len)
181 }
182
183 pub const fn thumb_travel(&self) -> usize {
185 self.track_len.saturating_sub(self.thumb_len)
186 }
187
188 pub const fn thumb_range(&self) -> Range<usize> {
190 self.thumb_start..self.thumb_start.saturating_add(self.thumb_len)
191 }
192
193 pub const fn hit_test(&self, position: usize) -> HitTest {
195 if position >= self.thumb_start
196 && position < self.thumb_start.saturating_add(self.thumb_len)
197 {
198 HitTest::Thumb
199 } else {
200 HitTest::Track
201 }
202 }
203
204 pub fn thumb_start_for_offset(&self, offset: usize) -> usize {
208 let max_offset = self.max_offset();
209 if max_offset == 0 {
210 return 0;
211 }
212 let offset = offset.min(max_offset);
213 self.thumb_travel().saturating_mul(offset) / max_offset
214 }
215
216 pub fn offset_for_thumb_start(&self, thumb_start: usize) -> usize {
220 let max_offset = self.max_offset();
221 if max_offset == 0 {
222 return 0;
223 }
224 let thumb_start = thumb_start.min(self.thumb_travel());
225 max_offset.saturating_mul(thumb_start) / self.thumb_travel()
226 }
227
228 pub fn cell_fill(&self, cell_index: usize) -> CellFill {
233 if self.thumb_len == 0 {
234 return CellFill::Empty;
235 }
236
237 let cell_start = cell_index.saturating_mul(SUBCELL);
238 let cell_end = cell_start.saturating_add(SUBCELL);
239
240 let thumb_end = self.thumb_start.saturating_add(self.thumb_len);
241 let start = self.thumb_start.max(cell_start);
242 let end = thumb_end.min(cell_end);
243
244 if end <= start {
245 return CellFill::Empty;
246 }
247
248 let len = end.saturating_sub(start).min(SUBCELL) as u8;
249 let start = start.saturating_sub(cell_start).min(SUBCELL) as u8;
250
251 if len as usize >= SUBCELL {
252 CellFill::Full
253 } else {
254 CellFill::Partial { start, len }
255 }
256 }
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262 #[test]
263 fn fills_track_when_no_scroll() {
264 let metrics = ScrollMetrics::new(
265 crate::ScrollLengths {
266 content_len: 10,
267 viewport_len: 10,
268 },
269 0,
270 4,
271 );
272 assert_eq!(metrics.thumb_len(), 32);
273 assert_eq!(metrics.thumb_start(), 0);
274 }
275
276 #[test]
277 fn clamps_offset_to_max() {
278 let metrics = ScrollMetrics::new(
279 crate::ScrollLengths {
280 content_len: 100,
281 viewport_len: 10,
282 },
283 200,
284 4,
285 );
286 assert_eq!(metrics.offset(), 90);
287 assert_eq!(metrics.thumb_start(), metrics.thumb_travel());
288 }
289
290 #[test]
291 fn reports_partial_cell_fills() {
292 let metrics = ScrollMetrics::new(
293 crate::ScrollLengths {
294 content_len: 10,
295 viewport_len: 3,
296 },
297 1,
298 4,
299 );
300 assert_eq!(metrics.cell_fill(0), CellFill::Partial { start: 3, len: 5 });
301 assert_eq!(metrics.cell_fill(1), CellFill::Partial { start: 0, len: 4 });
302 assert_eq!(metrics.cell_fill(2), CellFill::Empty);
303 }
304
305 #[test]
306 fn distinguishes_thumb_vs_track_hits() {
307 let metrics = ScrollMetrics::new(
308 crate::ScrollLengths {
309 content_len: 10,
310 viewport_len: 3,
311 },
312 1,
313 4,
314 );
315 assert_eq!(metrics.hit_test(0), HitTest::Track);
316 assert_eq!(metrics.hit_test(4), HitTest::Thumb);
317 assert_eq!(metrics.hit_test(12), HitTest::Track);
318 }
319
320 #[test]
321 fn stays_scale_invariant_for_logical_units() {
322 let track_cells = 10;
323 let base = ScrollMetrics::new(
324 crate::ScrollLengths {
325 content_len: 200,
326 viewport_len: 20,
327 },
328 10,
329 track_cells,
330 );
331 let scaled = ScrollMetrics::new(
332 crate::ScrollLengths {
333 content_len: 200 * SUBCELL,
334 viewport_len: 20 * SUBCELL,
335 },
336 10 * SUBCELL,
337 track_cells,
338 );
339 assert_eq!(base.thumb_len(), scaled.thumb_len());
340 assert_eq!(base.thumb_start(), scaled.thumb_start());
341 }
342
343 #[test]
344 fn yields_empty_thumb_when_track_len_zero() {
345 let lengths = crate::ScrollLengths {
346 content_len: 10,
347 viewport_len: 4,
348 };
349 let metrics = ScrollMetrics::new(lengths, 0, 0);
350 assert_eq!(metrics.track_len(), 0);
351 assert_eq!(metrics.thumb_len(), 0);
352 assert_eq!(metrics.cell_fill(0), CellFill::Empty);
353 }
354
355 #[test]
356 fn reports_full_cell_when_thumb_covers_track() {
357 let lengths = crate::ScrollLengths {
358 content_len: 8,
359 viewport_len: 8,
360 };
361 let metrics = ScrollMetrics::new(lengths, 0, 1);
362 assert_eq!(metrics.thumb_len(), SUBCELL);
363 assert_eq!(metrics.cell_fill(0), CellFill::Full);
364 }
365
366 #[test]
367 fn treats_zero_lengths_as_one() {
368 let lengths = crate::ScrollLengths {
369 content_len: 0,
370 viewport_len: 0,
371 };
372 let metrics = ScrollMetrics::new(lengths, 0, 1);
373 assert_eq!(metrics.content_len(), 1);
374 assert_eq!(metrics.viewport_len(), 1);
375 assert_eq!(metrics.thumb_len(), SUBCELL);
376 }
377
378 #[test]
379 fn thumb_start_for_offset_returns_zero_when_no_scroll() {
380 let lengths = crate::ScrollLengths {
381 content_len: 10,
382 viewport_len: 10,
383 };
384 let metrics = ScrollMetrics::new(lengths, 0, 4);
385 assert_eq!(metrics.thumb_start_for_offset(5), 0);
386 }
387
388 #[test]
389 fn offset_for_thumb_start_returns_zero_when_no_scroll() {
390 let lengths = crate::ScrollLengths {
391 content_len: 10,
392 viewport_len: 10,
393 };
394 let metrics = ScrollMetrics::new(lengths, 0, 4);
395 assert_eq!(metrics.offset_for_thumb_start(5), 0);
396 }
397
398 #[test]
399 fn hit_test_returns_track_before_thumb_start() {
400 let lengths = crate::ScrollLengths {
401 content_len: 10,
402 viewport_len: 3,
403 };
404 let metrics = ScrollMetrics::new(lengths, 1, 4);
405 assert_eq!(
406 metrics.hit_test(metrics.thumb_start().saturating_sub(1)),
407 HitTest::Track
408 );
409 }
410
411 #[test]
412 fn hit_test_returns_track_at_thumb_end() {
413 let lengths = crate::ScrollLengths {
414 content_len: 10,
415 viewport_len: 3,
416 };
417 let metrics = ScrollMetrics::new(lengths, 1, 4);
418 let thumb_end = metrics.thumb_start().saturating_add(metrics.thumb_len());
419 assert_eq!(metrics.hit_test(thumb_end), HitTest::Track);
420 }
421
422 #[test]
423 fn reports_empty_cell_fill_when_thumb_len_zero() {
424 let lengths = crate::ScrollLengths {
425 content_len: 10,
426 viewport_len: 4,
427 };
428 let metrics = ScrollMetrics::new(lengths, 0, 0);
429 assert_eq!(metrics.cell_fill(0), CellFill::Empty);
430 }
431
432 #[test]
433 fn thumb_range_matches_start_and_len() {
434 let lengths = crate::ScrollLengths {
435 content_len: 10,
436 viewport_len: 3,
437 };
438 let metrics = ScrollMetrics::new(lengths, 1, 4);
439 assert_eq!(
440 metrics.thumb_range(),
441 metrics.thumb_start()..metrics.thumb_start().saturating_add(metrics.thumb_len())
442 );
443 }
444
445 #[test]
446 fn clamps_thumb_start_for_offset() {
447 let lengths = crate::ScrollLengths {
448 content_len: 100,
449 viewport_len: 10,
450 };
451 let metrics = ScrollMetrics::new(lengths, 0, 4);
452 let max_offset = metrics.max_offset();
453 assert_eq!(
454 metrics.thumb_start_for_offset(max_offset.saturating_add(10)),
455 metrics.thumb_travel()
456 );
457 }
458
459 #[test]
460 fn clamps_offset_for_thumb_start() {
461 let lengths = crate::ScrollLengths {
462 content_len: 100,
463 viewport_len: 10,
464 };
465 let metrics = ScrollMetrics::new(lengths, 0, 4);
466 let max_offset = metrics.max_offset();
467 assert_eq!(
468 metrics.offset_for_thumb_start(metrics.thumb_travel().saturating_add(10)),
469 max_offset
470 );
471 }
472}