1use crate::round_scaling_list::ScalingParams;
20use crate::scrollbar::{thumb_geometry, ThumbBounds};
21use std::f32::consts::FRAC_PI_2;
22
23pub const INDICATOR_HEIGHT_DP: f32 = 50.0;
26pub const INDICATOR_WIDTH_DP: f32 = 6.0;
29pub const INDICATOR_NARROW_WIDTH_DP: f32 = 5.0;
30pub const INDICATOR_LARGE_SCREEN_DP: f32 = 225.0;
32pub const INDICATOR_EDGE_PADDING_DP: f32 = 2.0;
35pub const INDICATOR_GAP_DP: f32 = 3.0;
38pub const INDICATOR_MIN_THUMB: f32 = 0.3;
41pub const INDICATOR_MAX_THUMB: f32 = 0.7;
42
43pub fn indicator_width_dp(display_dp: f32) -> f32 {
45 if display_dp.is_finite() && display_dp >= INDICATOR_LARGE_SCREEN_DP {
46 INDICATOR_WIDTH_DP
47 } else {
48 INDICATOR_NARROW_WIDTH_DP
49 }
50}
51
52#[derive(Clone, Copy, Debug, PartialEq)]
54pub struct IndicatorArc {
55 centreline: f32,
57 width: f32,
59 half_sweep: f32,
61 segment_inset: f32,
64}
65
66impl IndicatorArc {
67 pub fn centreline(self) -> f32 {
69 self.centreline
70 }
71
72 pub fn width(self) -> f32 {
74 self.width
75 }
76
77 pub fn segment_inset(self) -> f32 {
79 self.segment_inset
80 }
81
82 pub fn start_angle(self) -> f32 {
85 -self.half_sweep
86 }
87
88 pub fn sweep(self) -> f32 {
90 self.half_sweep * 2.0
91 }
92
93 pub fn cap_sweep(self) -> f32 {
99 if self.centreline > 0.0 {
100 self.width / self.centreline
101 } else {
102 0.0
103 }
104 }
105}
106
107fn height_to_sweep(height: f32, radius: f32) -> f32 {
108 if radius <= 0.0 || !radius.is_finite() {
109 return 0.0;
110 }
111 (height * 0.5 / radius).clamp(-1.0, 1.0).asin() * 2.0
112}
113
114pub fn indicator_arc(radius: f32) -> IndicatorArc {
126 let width = indicator_width_dp(radius * 2.0);
127 let usable_radius = radius - INDICATOR_EDGE_PADDING_DP;
128 let centreline = usable_radius - width * 0.5;
129 if centreline <= 0.0 || !centreline.is_finite() {
130 return IndicatorArc {
131 centreline: 0.0,
132 width,
133 half_sweep: 0.0,
134 segment_inset: 0.0,
135 };
136 }
137 let segment_inset = height_to_sweep(width + INDICATOR_GAP_DP, usable_radius);
138 let half_sweep = ((height_to_sweep(INDICATOR_HEIGHT_DP, usable_radius) + segment_inset) * 0.5)
139 .min(FRAC_PI_2);
140 IndicatorArc {
141 centreline,
142 width,
143 half_sweep,
144 segment_inset,
145 }
146}
147
148#[derive(Clone, Copy, Debug, PartialEq)]
151pub struct IndicatorGeometry {
152 pub thumb: f32,
154 pub offset: f32,
156}
157
158pub fn indicator_geometry(content: f32, viewport: f32, scrolled: f32) -> Option<IndicatorGeometry> {
170 thumb_geometry(
171 content,
172 viewport,
173 scrolled,
174 ThumbBounds::new(INDICATOR_MIN_THUMB, INDICATOR_MAX_THUMB),
175 )
176 .map(|geometry| IndicatorGeometry {
177 thumb: geometry.length,
178 offset: geometry.offset,
179 })
180}
181
182#[derive(Clone, Copy, Debug, PartialEq)]
193pub struct IndicatorItem {
194 pub index: usize,
196 pub start_offset: f32,
199 pub size: f32,
204}
205
206#[derive(Clone, Copy, Debug, PartialEq)]
208pub struct ScalingList<'a> {
209 pub visible: &'a [IndicatorItem],
214 pub total: usize,
217 pub viewport: f32,
219 pub before_padding: f32,
223 pub after_padding: f32,
226}
227
228pub fn decimal_first_item_index(list: ScalingList<'_>) -> f32 {
235 let Some(first) = list.visible.first() else {
236 return 0.0;
237 };
238 let offset_from_start = if first.index == 0 {
239 list.before_padding
240 } else {
241 0.0
242 };
243 let start = first.start_offset - offset_from_start;
244 let top = -(list.viewport / 2.0);
245 let fraction = ((top - start) / (first.size + offset_from_start).max(1.0)).max(0.0);
246 finite(first.index as f32 + fraction)
247}
248
249pub fn decimal_last_item_index(list: ScalingList<'_>) -> f32 {
254 let Some(last) = list.visible.last() else {
255 return 0.0;
256 };
257 let span = last.size
258 + if last.index + 1 == list.total {
259 list.after_padding
260 } else {
261 0.0
262 };
263 let end = last.start_offset + span;
264 let bottom = list.viewport / 2.0;
265 let fraction = (1.0 - (end - bottom) / span.max(1.0)).min(1.0);
266 finite(last.index as f32 + fraction)
267}
268
269pub fn position_fraction(list: ScalingList<'_>) -> f32 {
277 if list.visible.is_empty() {
278 return 0.0;
279 }
280 let first = decimal_first_item_index(list);
281 let remaining = list.total as f32 - decimal_last_item_index(list);
282 if first + remaining == 0.0 {
283 0.0
284 } else {
285 finite(first / (first + remaining))
286 }
287}
288
289#[derive(Clone, Copy, Debug, Default, PartialEq)]
303pub struct ThumbLength {
304 fraction: f32,
305 items: usize,
306}
307
308impl ThumbLength {
309 pub fn of(&mut self, list: ScalingList<'_>) -> f32 {
311 if list.visible.is_empty() {
312 return 0.0;
313 }
314 if self.items != list.total {
315 self.items = list.total;
316 let span = decimal_last_item_index(list) - decimal_first_item_index(list);
317 let share = span / list.total.max(1) as f32;
318 self.fraction = if share.is_finite() {
319 share.clamp(INDICATOR_MIN_THUMB, INDICATOR_MAX_THUMB)
320 } else {
321 INDICATOR_MIN_THUMB
322 };
323 }
324 self.fraction
325 }
326
327 pub fn forget(&mut self) {
329 *self = Self::default();
330 }
331}
332
333pub fn scaling_list_geometry(
347 thumb: &mut ThumbLength,
348 list: ScalingList<'_>,
349) -> Option<IndicatorGeometry> {
350 if list.visible.is_empty() || list.total == 0 || !list.viewport.is_finite() {
351 return None;
352 }
353 let size = thumb.of(list);
354 let position = position_fraction(list).clamp(0.0, 1.0);
355 Some(IndicatorGeometry {
356 thumb: size,
357 offset: position * (1.0 - size),
358 })
359}
360
361pub fn scaling_list_items<I>(viewport: f32, density: f32, rows: I, out: &mut Vec<IndicatorItem>)
377where
378 I: IntoIterator<Item = (f32, f32)>,
379{
380 scaling_list_items_with(ScalingParams::WEAR, viewport, density, rows, out)
381}
382
383pub fn scaling_list_items_with<I>(
392 params: ScalingParams,
393 viewport: f32,
394 density: f32,
395 rows: I,
396 out: &mut Vec<IndicatorItem>,
397) where
398 I: IntoIterator<Item = (f32, f32)>,
399{
400 out.clear();
401 if !viewport.is_finite() || !density.is_finite() {
402 return;
403 }
404 let pixels = density > 0.0;
408 let to_px = |value: f32| if pixels { value * density } else { value };
409 let round_px = |value: f32| if pixels { value.round() } else { value };
410 let viewport_px = round_px(to_px(viewport));
411 let centre_line = if pixels {
414 (viewport_px * 0.5).floor()
415 } else {
416 viewport_px * 0.5
417 };
418 for (index, (top, height)) in rows.into_iter().enumerate() {
419 let Some(placed) =
420 crate::round_scaling_list::place_row_with(params, viewport, top, height, density)
421 else {
422 continue;
423 };
424 let height_px = round_px(to_px(height));
425 let size = round_px(height_px * placed.scale);
426 let drawn_top = to_px(placed.top);
436 let carried = if pixels { odd_pixel(height_px) } else { 0.0 };
437 let stacked_top = drawn_top - carried + if pixels { odd_pixel(size) } else { 0.0 };
438 if stacked_top > viewport_px || stacked_top + size < 0.0 {
439 if out.is_empty() {
440 continue;
441 }
442 break;
443 }
444 out.push(IndicatorItem {
445 index,
446 start_offset: drawn_top - carried - centre_line,
447 size,
448 });
449 }
450}
451
452fn odd_pixel(pixels: f32) -> f32 {
454 let half = pixels * 0.5;
455 half - half.floor()
456}
457
458fn finite(value: f32) -> f32 {
459 if value.is_finite() {
460 value
461 } else {
462 0.0
463 }
464}
465
466#[derive(Clone, Copy, Debug, PartialEq)]
472pub enum IndicatorSegment {
473 Arc { start: f32, sweep: f32, alpha: f32 },
476 Dot {
478 angle: f32,
480 radius: f32,
482 alpha: f32,
483 },
484}
485
486#[derive(Clone, Copy, Debug, PartialEq, Eq)]
489pub enum IndicatorPart {
490 Track,
491 Thumb,
492}
493
494pub fn indicator_segments(
503 arc: IndicatorArc,
504 geometry: IndicatorGeometry,
505 alpha: f32,
506) -> [(IndicatorPart, IndicatorSegment); 3] {
507 let alpha = if alpha.is_finite() {
508 alpha.clamp(0.0, 1.0)
509 } else {
510 0.0
511 };
512 let thumb = if geometry.thumb.is_finite() {
513 geometry.thumb.clamp(0.0, 1.0)
514 } else {
515 0.0
516 };
517 let offset = if geometry.offset.is_finite() {
518 geometry.offset.clamp(0.0, 1.0 - thumb)
519 } else {
520 0.0
521 };
522 let sweep = arc.sweep();
523 let top = arc.start_angle();
524 let thumb_start = top + sweep * offset;
525 let thumb_sweep = sweep * thumb;
526 let below_start = thumb_start + thumb_sweep;
527 [
528 (
529 IndicatorPart::Track,
530 segment(top, thumb_start - top, arc.width, arc.segment_inset, alpha),
531 ),
532 (
533 IndicatorPart::Thumb,
534 segment(
535 thumb_start,
536 thumb_sweep,
537 arc.width,
538 arc.segment_inset,
539 alpha,
540 ),
541 ),
542 (
543 IndicatorPart::Track,
544 segment(
545 below_start,
546 top + sweep - below_start,
547 arc.width,
548 arc.segment_inset,
549 alpha,
550 ),
551 ),
552 ]
553}
554
555fn segment(start: f32, sweep: f32, width: f32, inset: f32, alpha: f32) -> IndicatorSegment {
557 if sweep <= 0.0 || inset <= 0.0 {
558 return IndicatorSegment::Arc {
559 start,
560 sweep: 0.0,
561 alpha: 0.0,
562 };
563 }
564 if sweep < inset {
565 let fill = sweep / inset;
569 return IndicatorSegment::Dot {
570 angle: start + sweep * 0.5,
571 radius: width * 0.5 * fill,
572 alpha: alpha * fill,
573 };
574 }
575 IndicatorSegment::Arc {
578 start: start + inset * 0.5,
579 sweep: sweep - inset,
580 alpha,
581 }
582}
583
584#[cfg(test)]
585mod tests {
586 use super::*;
587
588 const LARGE_RADIUS_DP: f32 = 113.5; const SMALL_RADIUS_DP: f32 = 96.0; #[test]
593 fn stroke_width_switches_at_the_wear_large_screen_breakpoint() {
594 assert_eq!(indicator_width_dp(224.99), INDICATOR_NARROW_WIDTH_DP);
595 assert_eq!(indicator_width_dp(225.0), INDICATOR_WIDTH_DP);
596 assert_eq!(indicator_width_dp(f32::NAN), INDICATOR_NARROW_WIDTH_DP);
597 }
598
599 #[test]
600 fn the_track_lands_where_the_shipping_compose_build_draws_it() {
601 let large = indicator_arc(LARGE_RADIUS_DP);
605 assert!((large.centreline() - 108.5).abs() < 0.01, "{large:?}");
606 assert!((large.width() - 6.0).abs() < 0.01, "{large:?}");
607
608 let small = indicator_arc(SMALL_RADIUS_DP);
609 assert!((small.centreline() - 91.5).abs() < 0.01, "{small:?}");
610 assert!((small.width() - 5.0).abs() < 0.01, "{small:?}");
611 }
612
613 #[test]
614 fn the_sweep_is_a_height_in_dp_not_a_fixed_angle() {
615 let large = indicator_arc(LARGE_RADIUS_DP).sweep().to_degrees();
618 let small = indicator_arc(SMALL_RADIUS_DP).sweep().to_degrees();
619 assert!((large - 30.54).abs() < 0.05, "{large}");
620 assert!((small - 35.73).abs() < 0.05, "{small}");
621 assert!(small > large);
622 }
623
624 #[test]
625 fn a_list_that_fits_on_screen_shows_no_indicator_at_all() {
626 assert_eq!(indicator_geometry(100.0, 100.0, 0.0), None);
627 assert_eq!(indicator_geometry(80.0, 100.0, 0.0), None);
628 assert_eq!(indicator_geometry(f32::NAN, 100.0, 0.0), None);
629 assert_eq!(indicator_geometry(200.0, 0.0, 0.0), None);
630 }
631
632 #[test]
633 fn the_thumb_is_the_viewport_share_clamped_at_both_ends() {
634 let half = indicator_geometry(200.0, 100.0, 0.0).unwrap();
636 assert!((half.thumb - 0.5).abs() < 1e-6, "{half:?}");
637 let long = indicator_geometry(10_000.0, 100.0, 0.0).unwrap();
640 assert!((long.thumb - INDICATOR_MIN_THUMB).abs() < 1e-6, "{long:?}");
641 let short = indicator_geometry(105.0, 100.0, 0.0).unwrap();
642 assert!(
643 (short.thumb - INDICATOR_MAX_THUMB).abs() < 1e-6,
644 "{short:?}"
645 );
646 }
647
648 #[test]
649 fn the_thumb_reaches_the_bottom_of_the_track_and_no_further() {
650 let bottom = indicator_geometry(200.0, 100.0, 100.0).unwrap();
651 assert!(
652 (bottom.offset + bottom.thumb - 1.0).abs() < 1e-6,
653 "{bottom:?}"
654 );
655 let past = indicator_geometry(200.0, 100.0, 500.0).unwrap();
657 assert_eq!(past, bottom);
658 }
659
660 #[test]
661 fn the_indicator_is_three_segments_with_a_gap_either_side_of_the_thumb() {
662 let arc = indicator_arc(LARGE_RADIUS_DP);
663 let geometry = IndicatorGeometry {
664 thumb: 0.4,
665 offset: 0.3,
666 };
667 let parts = indicator_segments(arc, geometry, 1.0);
668 assert_eq!(parts[0].0, IndicatorPart::Track);
669 assert_eq!(parts[1].0, IndicatorPart::Thumb);
670 assert_eq!(parts[2].0, IndicatorPart::Track);
671
672 let ink_bounds = |segment: IndicatorSegment| match segment {
676 IndicatorSegment::Arc { start, sweep, .. } => {
677 (start - arc.cap_sweep() * 0.5, sweep + arc.cap_sweep())
678 }
679 other => panic!("expected an arc, got {other:?}"),
680 };
681 let (above_start, above_sweep) = ink_bounds(parts[0].1);
682 let (thumb_start, thumb_sweep) = ink_bounds(parts[1].1);
683 let (below_start, below_sweep) = ink_bounds(parts[2].1);
684 let gap = arc.segment_inset() - arc.cap_sweep();
685
686 assert!((above_start - arc.start_angle() - gap * 0.5).abs() < 1e-4);
687 assert!((thumb_start - (above_start + above_sweep) - gap).abs() < 1e-4);
688 assert!((below_start - (thumb_start + thumb_sweep) - gap).abs() < 1e-4);
689 assert!(
690 (below_start + below_sweep + gap * 0.5 - (arc.start_angle() + arc.sweep())).abs()
691 < 1e-4,
692 "the track has to end where it should"
693 );
694 }
695
696 #[test]
697 fn a_segment_shorter_than_its_stroke_becomes_a_shrinking_dot() {
698 let arc = indicator_arc(LARGE_RADIUS_DP);
699 let parts = indicator_segments(
701 arc,
702 IndicatorGeometry {
703 thumb: 0.7,
704 offset: 0.0,
705 },
706 1.0,
707 );
708 match parts[0].1 {
709 IndicatorSegment::Dot { radius, alpha, .. } => {
710 assert!(
711 radius <= arc.width() * 0.5,
712 "a dot never exceeds the stroke"
713 );
714 assert!(alpha < 1.0, "it fades on the same fraction as it shrinks");
715 }
716 IndicatorSegment::Arc { sweep, .. } => {
717 assert!(sweep <= 0.0, "an arc this short should have been a dot");
718 }
719 }
720 }
721
722 #[test]
723 fn fading_the_indicator_fades_every_piece_of_it() {
724 let arc = indicator_arc(LARGE_RADIUS_DP);
725 let geometry = IndicatorGeometry {
726 thumb: 0.4,
727 offset: 0.3,
728 };
729 for (_, segment) in indicator_segments(arc, geometry, 0.25) {
730 let alpha = match segment {
731 IndicatorSegment::Arc { alpha, .. } => alpha,
732 IndicatorSegment::Dot { alpha, .. } => alpha,
733 };
734 assert!(alpha <= 0.25 + 1e-6, "{segment:?}");
735 }
736 }
737
738 #[test]
739 fn a_display_too_small_to_hold_the_track_degrades_instead_of_panicking() {
740 let tiny = indicator_arc(1.0);
741 assert_eq!(tiny.centreline(), 0.0);
742 assert_eq!(tiny.sweep(), 0.0);
743 assert_eq!(tiny.cap_sweep(), 0.0);
744 let parts = indicator_segments(
746 tiny,
747 IndicatorGeometry {
748 thumb: 0.4,
749 offset: 0.3,
750 },
751 1.0,
752 );
753 for (_, segment) in parts {
754 assert!(matches!(segment, IndicatorSegment::Arc { sweep: 0.0, .. }));
755 }
756 }
757
758 const VIEWPORT: f32 = 400.0;
762
763 fn list<'a>(visible: &'a [IndicatorItem]) -> ScalingList<'a> {
764 ScalingList {
765 visible,
766 total: 10,
767 viewport: VIEWPORT,
768 before_padding: 0.0,
769 after_padding: 0.0,
770 }
771 }
772
773 fn row(index: usize, start_offset: f32) -> IndicatorItem {
774 IndicatorItem {
775 index,
776 start_offset,
777 size: 100.0,
778 }
779 }
780
781 #[test]
782 fn a_row_flush_with_the_top_of_the_screen_is_a_whole_index() {
783 let rows = [row(3, -200.0), row(6, 100.0)];
786 assert_eq!(decimal_first_item_index(list(&rows)), 3.0);
787 }
788
789 #[test]
790 fn a_row_half_off_the_top_reads_half_an_index() {
791 let rows = [row(3, -250.0), row(6, 100.0)];
795 assert_eq!(decimal_first_item_index(list(&rows)), 3.5);
796 }
797
798 #[test]
799 fn the_last_index_counts_how_much_of_the_row_is_on_screen() {
800 let rows = [row(3, -200.0), row(6, 150.0)];
803 assert_eq!(decimal_last_item_index(list(&rows)), 6.5);
804 }
805
806 #[test]
807 fn the_padding_outside_the_list_counts_only_at_the_end_it_belongs_to() {
808 let rows = [row(0, -250.0), row(9, 150.0)];
809 let padded = ScalingList {
810 before_padding: 80.0,
811 after_padding: 60.0,
812 ..list(&rows)
813 };
814 assert!((decimal_first_item_index(padded) - 130.0 / 180.0).abs() < 1e-6);
817 assert!((decimal_last_item_index(padded) - (9.0 + 0.3125)).abs() < 1e-6);
819
820 let inner = [row(3, -250.0), row(6, 150.0)];
822 let inner = ScalingList {
823 before_padding: 80.0,
824 after_padding: 60.0,
825 ..list(&inner)
826 };
827 assert_eq!(decimal_first_item_index(inner), 3.5);
828 assert_eq!(decimal_last_item_index(inner), 6.5);
829 }
830
831 #[test]
832 fn the_thumb_is_the_share_of_the_items_on_screen_not_of_the_pixels() {
833 let rows = [row(3, -250.0), row(8, 150.0)];
836 let mut thumb = ThumbLength::default();
837 assert!((thumb.of(list(&rows)) - 0.5).abs() < 1e-6);
838 }
839
840 #[test]
841 fn the_thumb_is_clamped_at_both_ends_however_long_the_list_is() {
842 let rows = [row(3, -250.0), row(4, 150.0)];
843 let mut short = ThumbLength::default();
844 assert_eq!(short.of(list(&rows)), INDICATOR_MIN_THUMB);
845
846 let rows = [row(0, -250.0), row(9, 150.0)];
847 let mut long = ThumbLength::default();
848 assert_eq!(long.of(list(&rows)), INDICATOR_MAX_THUMB);
849 }
850
851 #[test]
852 fn the_thumb_is_measured_once_and_then_only_when_the_list_changes_length() {
853 let mut thumb = ThumbLength::default();
857 let five = [row(3, -250.0), row(8, 150.0)];
858 assert!((thumb.of(list(&five)) - 0.5).abs() < 1e-6);
859
860 let three = [row(3, -250.0), row(6, 150.0)];
861 assert!(
862 (thumb.of(list(&three)) - 0.5).abs() < 1e-6,
863 "the window shrank but the list did not, so the thumb holds"
864 );
865
866 let longer = ScalingList {
867 total: 20,
868 ..list(&three)
869 };
870 assert_eq!(thumb.of(longer), INDICATOR_MIN_THUMB);
871
872 thumb.forget();
873 assert!((thumb.of(list(&five)) - 0.5).abs() < 1e-6);
874 }
875
876 #[test]
877 fn the_position_is_how_many_items_are_left_not_how_far_the_pixels_went() {
878 let rows = [row(3, -250.0), row(6, 150.0)];
880 assert!((position_fraction(list(&rows)) - 0.5).abs() < 1e-6);
881 }
882
883 #[test]
884 fn a_list_at_the_top_puts_the_thumb_at_the_top_and_one_at_the_end_at_the_end() {
885 let mut thumb = ThumbLength::default();
886 let top = [row(0, -200.0), row(3, 150.0)];
887 let geometry = scaling_list_geometry(&mut thumb, list(&top)).unwrap();
888 assert_eq!(geometry.offset, 0.0);
889
890 let mut thumb = ThumbLength::default();
893 let end = [row(6, -250.0), row(9, 100.0)];
894 let geometry = scaling_list_geometry(&mut thumb, list(&end)).unwrap();
895 assert_eq!(decimal_last_item_index(list(&end)), 10.0);
896 assert!((geometry.offset + geometry.thumb - 1.0).abs() < 1e-6);
897 }
898
899 #[test]
900 fn a_list_with_nothing_on_screen_has_no_indicator() {
901 let mut thumb = ThumbLength::default();
902 assert_eq!(scaling_list_geometry(&mut thumb, list(&[])), None);
903 let rows = [row(3, -250.0)];
904 let empty = ScalingList {
905 total: 0,
906 ..list(&rows)
907 };
908 assert_eq!(scaling_list_geometry(&mut thumb, empty), None);
909 }
910
911 #[test]
912 fn the_two_models_disagree_the_moment_the_rows_are_not_all_the_same_height() {
913 let heights: Vec<f32> = std::iter::once(600.0).chain([100.0; 9]).collect();
920 let content: f32 = heights.iter().sum();
921 let pixel = indicator_geometry(content, VIEWPORT, 0.0).unwrap();
922
923 let rows = [row(0, -200.0), row(3, 100.0)];
924 let mut thumb = ThumbLength::default();
925 let wear = scaling_list_geometry(&mut thumb, list(&rows)).unwrap();
926
927 assert!((pixel.thumb - INDICATOR_MIN_THUMB).abs() < 1e-6);
928 assert!((wear.thumb - 0.4).abs() < 1e-6, "{wear:?}");
929 }
930
931 #[test]
932 fn a_reported_row_is_not_the_row_as_it_is_drawn() {
933 let mut out = Vec::new();
937 let density = 2.0;
938 let viewport = 227.0;
939 for (height, carried) in [(51.5, 0.5), (52.0, 0.0)] {
940 scaling_list_items(viewport, density, [(20.0, height)], &mut out);
941 let drawn = crate::round_scaling_list::place_row(viewport, 20.0, height, density)
942 .expect("placed");
943 let item = out.first().expect("on screen");
944 assert!(
945 (item.start_offset - (drawn.top * density - carried - 227.0)).abs() < 1e-4,
946 "{height}dp: reported {} against drawn {}",
947 item.start_offset,
948 drawn.top * density
949 );
950 assert_eq!(item.size, (drawn.height * density).round());
953 }
954 }
955
956 #[test]
957 fn a_list_that_does_not_scale_its_rows_reports_them_at_full_height() {
958 let mut wear = Vec::new();
964 let mut still = Vec::new();
965 let rows = [(4.0, 52.0), (60.0, 52.0), (116.0, 52.0)];
966 scaling_list_items(227.0, 2.0, rows, &mut wear);
967 scaling_list_items_with(
968 ScalingParams::WEAR.reduced_motion(),
969 227.0,
970 2.0,
971 rows,
972 &mut still,
973 );
974 assert_eq!(wear.len(), still.len());
975 assert!(
976 wear[0].size < still[0].size,
977 "the top row shrinks under the Wear ramp and not under a stilled \
978 one: {} vs {}",
979 wear[0].size,
980 still[0].size
981 );
982 assert_eq!(still[0].size, 104.0, "52dp at density 2, unscaled");
983 let mut default = Vec::new();
985 scaling_list_items_with(ScalingParams::WEAR, 227.0, 2.0, rows, &mut default);
986 assert_eq!(default, wear);
987 }
988
989 #[test]
990 fn the_window_is_the_rows_that_still_meet_the_display() {
991 let mut out = Vec::new();
996 let rows: Vec<(f32, f32)> = (0..10)
997 .map(|index| (index as f32 * 40.0 - 100.0, 40.0))
998 .collect();
999 scaling_list_items(227.0, 2.0, rows.iter().copied(), &mut out);
1000 let indices: Vec<usize> = out.iter().map(|item| item.index).collect();
1001 assert_eq!(indices, vec![2, 3, 4, 5, 6, 7, 8]);
1002 }
1003
1004 #[test]
1005 fn invalid_scaling_list_input_never_produces_a_non_finite_thumb() {
1006 let mut out = Vec::new();
1007 scaling_list_items(f32::NAN, 2.0, [(0.0, 40.0)], &mut out);
1008 assert!(out.is_empty());
1009 scaling_list_items(227.0, f32::NAN, [(0.0, 40.0)], &mut out);
1010 assert!(out.is_empty());
1011
1012 let rows = [
1013 IndicatorItem {
1014 index: 0,
1015 start_offset: f32::NAN,
1016 size: 0.0,
1017 },
1018 IndicatorItem {
1019 index: 3,
1020 start_offset: f32::INFINITY,
1021 size: -1.0,
1022 },
1023 ];
1024 let mut thumb = ThumbLength::default();
1025 let geometry = scaling_list_geometry(&mut thumb, list(&rows)).expect("a geometry");
1026 assert!(
1027 geometry.thumb.is_finite() && geometry.offset.is_finite(),
1028 "{geometry:?}"
1029 );
1030 assert!(geometry.thumb >= INDICATOR_MIN_THUMB && geometry.thumb <= INDICATOR_MAX_THUMB);
1031 assert!(geometry.offset >= 0.0 && geometry.offset <= 1.0);
1032 }
1033
1034 #[test]
1035 fn invalid_public_inputs_never_emit_non_finite_draw_values() {
1036 for radius in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -1.0] {
1037 let arc = indicator_arc(radius);
1038 assert_eq!(arc.sweep(), 0.0);
1039 assert_eq!(arc.segment_inset(), 0.0);
1040 }
1041
1042 let parts = indicator_segments(
1043 indicator_arc(LARGE_RADIUS_DP),
1044 IndicatorGeometry {
1045 thumb: f32::NAN,
1046 offset: f32::INFINITY,
1047 },
1048 f32::NAN,
1049 );
1050 for (_, part) in parts {
1051 match part {
1052 IndicatorSegment::Arc {
1053 start,
1054 sweep,
1055 alpha,
1056 } => assert!(start.is_finite() && sweep.is_finite() && alpha == 0.0),
1057 IndicatorSegment::Dot {
1058 angle,
1059 radius,
1060 alpha,
1061 } => assert!(angle.is_finite() && radius.is_finite() && alpha == 0.0),
1062 }
1063 }
1064 }
1065}