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