1use std::f32::consts::FRAC_PI_2;
20
21use crate::{
22 round_scaling_list::ScalingParams,
23 scrollbar::{thumb_geometry, ThumbBounds},
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() {
463 value
464 } else {
465 0.0
466 }
467}
468
469#[derive(Clone, Copy, Debug, PartialEq)]
475pub enum IndicatorSegment {
476 Arc { start: f32, sweep: f32, alpha: f32 },
479 Dot {
481 angle: f32,
483 radius: f32,
485 alpha: f32,
486 },
487}
488
489#[derive(Clone, Copy, Debug, PartialEq, Eq)]
492pub enum IndicatorPart {
493 Track,
494 Thumb,
495}
496
497pub fn indicator_segments(
506 arc: IndicatorArc,
507 geometry: IndicatorGeometry,
508 alpha: f32,
509) -> [(IndicatorPart, IndicatorSegment); 3] {
510 let alpha = if alpha.is_finite() {
511 alpha.clamp(0.0, 1.0)
512 } else {
513 0.0
514 };
515 let thumb = if geometry.thumb.is_finite() {
516 geometry.thumb.clamp(0.0, 1.0)
517 } else {
518 0.0
519 };
520 let offset = if geometry.offset.is_finite() {
521 geometry.offset.clamp(0.0, 1.0 - thumb)
522 } else {
523 0.0
524 };
525 let sweep = arc.sweep();
526 let top = arc.start_angle();
527 let thumb_start = top + sweep * offset;
528 let thumb_sweep = sweep * thumb;
529 let below_start = thumb_start + thumb_sweep;
530 [
531 (
532 IndicatorPart::Track,
533 segment(top, thumb_start - top, arc.width, arc.segment_inset, alpha),
534 ),
535 (
536 IndicatorPart::Thumb,
537 segment(
538 thumb_start,
539 thumb_sweep,
540 arc.width,
541 arc.segment_inset,
542 alpha,
543 ),
544 ),
545 (
546 IndicatorPart::Track,
547 segment(
548 below_start,
549 top + sweep - below_start,
550 arc.width,
551 arc.segment_inset,
552 alpha,
553 ),
554 ),
555 ]
556}
557
558fn segment(start: f32, sweep: f32, width: f32, inset: f32, alpha: f32) -> IndicatorSegment {
560 if sweep <= 0.0 || inset <= 0.0 {
561 return IndicatorSegment::Arc {
562 start,
563 sweep: 0.0,
564 alpha: 0.0,
565 };
566 }
567 if sweep < inset {
568 let fill = sweep / inset;
572 return IndicatorSegment::Dot {
573 angle: start + sweep * 0.5,
574 radius: width * 0.5 * fill,
575 alpha: alpha * fill,
576 };
577 }
578 IndicatorSegment::Arc {
581 start: start + inset * 0.5,
582 sweep: sweep - inset,
583 alpha,
584 }
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590
591 const LARGE_RADIUS_DP: f32 = 113.5; const SMALL_RADIUS_DP: f32 = 96.0; #[test]
596 fn stroke_width_switches_at_the_wear_large_screen_breakpoint() {
597 assert_eq!(indicator_width_dp(224.99), INDICATOR_NARROW_WIDTH_DP);
598 assert_eq!(indicator_width_dp(225.0), INDICATOR_WIDTH_DP);
599 assert_eq!(indicator_width_dp(f32::NAN), INDICATOR_NARROW_WIDTH_DP);
600 }
601
602 #[test]
603 fn the_track_lands_where_the_shipping_compose_build_draws_it() {
604 let large = indicator_arc(LARGE_RADIUS_DP);
608 assert!((large.centreline() - 108.5).abs() < 0.01, "{large:?}");
609 assert!((large.width() - 6.0).abs() < 0.01, "{large:?}");
610
611 let small = indicator_arc(SMALL_RADIUS_DP);
612 assert!((small.centreline() - 91.5).abs() < 0.01, "{small:?}");
613 assert!((small.width() - 5.0).abs() < 0.01, "{small:?}");
614 }
615
616 #[test]
617 fn the_sweep_is_a_height_in_dp_not_a_fixed_angle() {
618 let large = indicator_arc(LARGE_RADIUS_DP).sweep().to_degrees();
621 let small = indicator_arc(SMALL_RADIUS_DP).sweep().to_degrees();
622 assert!((large - 30.54).abs() < 0.05, "{large}");
623 assert!((small - 35.73).abs() < 0.05, "{small}");
624 assert!(small > large);
625 }
626
627 #[test]
628 fn a_list_that_fits_on_screen_shows_no_indicator_at_all() {
629 assert_eq!(indicator_geometry(100.0, 100.0, 0.0), None);
630 assert_eq!(indicator_geometry(80.0, 100.0, 0.0), None);
631 assert_eq!(indicator_geometry(f32::NAN, 100.0, 0.0), None);
632 assert_eq!(indicator_geometry(200.0, 0.0, 0.0), None);
633 }
634
635 #[test]
636 fn the_thumb_is_the_viewport_share_clamped_at_both_ends() {
637 let half = indicator_geometry(200.0, 100.0, 0.0).unwrap();
639 assert!((half.thumb - 0.5).abs() < 1e-6, "{half:?}");
640 let long = indicator_geometry(10_000.0, 100.0, 0.0).unwrap();
643 assert!((long.thumb - INDICATOR_MIN_THUMB).abs() < 1e-6, "{long:?}");
644 let short = indicator_geometry(105.0, 100.0, 0.0).unwrap();
645 assert!(
646 (short.thumb - INDICATOR_MAX_THUMB).abs() < 1e-6,
647 "{short:?}"
648 );
649 }
650
651 #[test]
652 fn the_thumb_reaches_the_bottom_of_the_track_and_no_further() {
653 let bottom = indicator_geometry(200.0, 100.0, 100.0).unwrap();
654 assert!(
655 (bottom.offset + bottom.thumb - 1.0).abs() < 1e-6,
656 "{bottom:?}"
657 );
658 let past = indicator_geometry(200.0, 100.0, 500.0).unwrap();
660 assert_eq!(past, bottom);
661 }
662
663 #[test]
664 fn the_indicator_is_three_segments_with_a_gap_either_side_of_the_thumb() {
665 let arc = indicator_arc(LARGE_RADIUS_DP);
666 let geometry = IndicatorGeometry {
667 thumb: 0.4,
668 offset: 0.3,
669 };
670 let parts = indicator_segments(arc, geometry, 1.0);
671 assert_eq!(parts[0].0, IndicatorPart::Track);
672 assert_eq!(parts[1].0, IndicatorPart::Thumb);
673 assert_eq!(parts[2].0, IndicatorPart::Track);
674
675 let ink_bounds = |segment: IndicatorSegment| match segment {
679 IndicatorSegment::Arc { start, sweep, .. } => {
680 (start - arc.cap_sweep() * 0.5, sweep + arc.cap_sweep())
681 }
682 other => panic!("expected an arc, got {other:?}"),
683 };
684 let (above_start, above_sweep) = ink_bounds(parts[0].1);
685 let (thumb_start, thumb_sweep) = ink_bounds(parts[1].1);
686 let (below_start, below_sweep) = ink_bounds(parts[2].1);
687 let gap = arc.segment_inset() - arc.cap_sweep();
688
689 assert!((above_start - arc.start_angle() - gap * 0.5).abs() < 1e-4);
690 assert!((thumb_start - (above_start + above_sweep) - gap).abs() < 1e-4);
691 assert!((below_start - (thumb_start + thumb_sweep) - gap).abs() < 1e-4);
692 assert!(
693 (below_start + below_sweep + gap * 0.5 - (arc.start_angle() + arc.sweep())).abs()
694 < 1e-4,
695 "the track has to end where it should"
696 );
697 }
698
699 #[test]
700 fn a_segment_shorter_than_its_stroke_becomes_a_shrinking_dot() {
701 let arc = indicator_arc(LARGE_RADIUS_DP);
702 let parts = indicator_segments(
704 arc,
705 IndicatorGeometry {
706 thumb: 0.7,
707 offset: 0.0,
708 },
709 1.0,
710 );
711 match parts[0].1 {
712 IndicatorSegment::Dot { radius, alpha, .. } => {
713 assert!(
714 radius <= arc.width() * 0.5,
715 "a dot never exceeds the stroke"
716 );
717 assert!(alpha < 1.0, "it fades on the same fraction as it shrinks");
718 }
719 IndicatorSegment::Arc { sweep, .. } => {
720 assert!(sweep <= 0.0, "an arc this short should have been a dot");
721 }
722 }
723 }
724
725 #[test]
726 fn fading_the_indicator_fades_every_piece_of_it() {
727 let arc = indicator_arc(LARGE_RADIUS_DP);
728 let geometry = IndicatorGeometry {
729 thumb: 0.4,
730 offset: 0.3,
731 };
732 for (_, segment) in indicator_segments(arc, geometry, 0.25) {
733 let alpha = match segment {
734 IndicatorSegment::Arc { alpha, .. } => alpha,
735 IndicatorSegment::Dot { alpha, .. } => alpha,
736 };
737 assert!(alpha <= 0.25 + 1e-6, "{segment:?}");
738 }
739 }
740
741 #[test]
742 fn a_display_too_small_to_hold_the_track_degrades_instead_of_panicking() {
743 let tiny = indicator_arc(1.0);
744 assert_eq!(tiny.centreline(), 0.0);
745 assert_eq!(tiny.sweep(), 0.0);
746 assert_eq!(tiny.cap_sweep(), 0.0);
747 let parts = indicator_segments(
749 tiny,
750 IndicatorGeometry {
751 thumb: 0.4,
752 offset: 0.3,
753 },
754 1.0,
755 );
756 for (_, segment) in parts {
757 assert!(matches!(segment, IndicatorSegment::Arc { sweep: 0.0, .. }));
758 }
759 }
760
761 const VIEWPORT: f32 = 400.0;
765
766 fn list<'a>(visible: &'a [IndicatorItem]) -> ScalingList<'a> {
767 ScalingList {
768 visible,
769 total: 10,
770 viewport: VIEWPORT,
771 before_padding: 0.0,
772 after_padding: 0.0,
773 }
774 }
775
776 fn row(index: usize, start_offset: f32) -> IndicatorItem {
777 IndicatorItem {
778 index,
779 start_offset,
780 size: 100.0,
781 }
782 }
783
784 #[test]
785 fn a_row_flush_with_the_top_of_the_screen_is_a_whole_index() {
786 let rows = [row(3, -200.0), row(6, 100.0)];
789 assert_eq!(decimal_first_item_index(list(&rows)), 3.0);
790 }
791
792 #[test]
793 fn a_row_half_off_the_top_reads_half_an_index() {
794 let rows = [row(3, -250.0), row(6, 100.0)];
798 assert_eq!(decimal_first_item_index(list(&rows)), 3.5);
799 }
800
801 #[test]
802 fn the_last_index_counts_how_much_of_the_row_is_on_screen() {
803 let rows = [row(3, -200.0), row(6, 150.0)];
806 assert_eq!(decimal_last_item_index(list(&rows)), 6.5);
807 }
808
809 #[test]
810 fn the_padding_outside_the_list_counts_only_at_the_end_it_belongs_to() {
811 let rows = [row(0, -250.0), row(9, 150.0)];
812 let padded = ScalingList {
813 before_padding: 80.0,
814 after_padding: 60.0,
815 ..list(&rows)
816 };
817 assert!((decimal_first_item_index(padded) - 130.0 / 180.0).abs() < 1e-6);
820 assert!((decimal_last_item_index(padded) - (9.0 + 0.3125)).abs() < 1e-6);
822
823 let inner = [row(3, -250.0), row(6, 150.0)];
825 let inner = ScalingList {
826 before_padding: 80.0,
827 after_padding: 60.0,
828 ..list(&inner)
829 };
830 assert_eq!(decimal_first_item_index(inner), 3.5);
831 assert_eq!(decimal_last_item_index(inner), 6.5);
832 }
833
834 #[test]
835 fn the_thumb_is_the_share_of_the_items_on_screen_not_of_the_pixels() {
836 let rows = [row(3, -250.0), row(8, 150.0)];
839 let mut thumb = ThumbLength::default();
840 assert!((thumb.of(list(&rows)) - 0.5).abs() < 1e-6);
841 }
842
843 #[test]
844 fn the_thumb_is_clamped_at_both_ends_however_long_the_list_is() {
845 let rows = [row(3, -250.0), row(4, 150.0)];
846 let mut short = ThumbLength::default();
847 assert_eq!(short.of(list(&rows)), INDICATOR_MIN_THUMB);
848
849 let rows = [row(0, -250.0), row(9, 150.0)];
850 let mut long = ThumbLength::default();
851 assert_eq!(long.of(list(&rows)), INDICATOR_MAX_THUMB);
852 }
853
854 #[test]
855 fn the_thumb_is_measured_once_and_then_only_when_the_list_changes_length() {
856 let mut thumb = ThumbLength::default();
860 let five = [row(3, -250.0), row(8, 150.0)];
861 assert!((thumb.of(list(&five)) - 0.5).abs() < 1e-6);
862
863 let three = [row(3, -250.0), row(6, 150.0)];
864 assert!(
865 (thumb.of(list(&three)) - 0.5).abs() < 1e-6,
866 "the window shrank but the list did not, so the thumb holds"
867 );
868
869 let longer = ScalingList {
870 total: 20,
871 ..list(&three)
872 };
873 assert_eq!(thumb.of(longer), INDICATOR_MIN_THUMB);
874
875 thumb.forget();
876 assert!((thumb.of(list(&five)) - 0.5).abs() < 1e-6);
877 }
878
879 #[test]
880 fn the_position_is_how_many_items_are_left_not_how_far_the_pixels_went() {
881 let rows = [row(3, -250.0), row(6, 150.0)];
883 assert!((position_fraction(list(&rows)) - 0.5).abs() < 1e-6);
884 }
885
886 #[test]
887 fn a_list_at_the_top_puts_the_thumb_at_the_top_and_one_at_the_end_at_the_end() {
888 let mut thumb = ThumbLength::default();
889 let top = [row(0, -200.0), row(3, 150.0)];
890 let geometry = scaling_list_geometry(&mut thumb, list(&top)).unwrap();
891 assert_eq!(geometry.offset, 0.0);
892
893 let mut thumb = ThumbLength::default();
896 let end = [row(6, -250.0), row(9, 100.0)];
897 let geometry = scaling_list_geometry(&mut thumb, list(&end)).unwrap();
898 assert_eq!(decimal_last_item_index(list(&end)), 10.0);
899 assert!((geometry.offset + geometry.thumb - 1.0).abs() < 1e-6);
900 }
901
902 #[test]
903 fn a_list_with_nothing_on_screen_has_no_indicator() {
904 let mut thumb = ThumbLength::default();
905 assert_eq!(scaling_list_geometry(&mut thumb, list(&[])), None);
906 let rows = [row(3, -250.0)];
907 let empty = ScalingList {
908 total: 0,
909 ..list(&rows)
910 };
911 assert_eq!(scaling_list_geometry(&mut thumb, empty), None);
912 }
913
914 #[test]
915 fn the_two_models_disagree_the_moment_the_rows_are_not_all_the_same_height() {
916 let heights: Vec<f32> = std::iter::once(600.0).chain([100.0; 9]).collect();
923 let content: f32 = heights.iter().sum();
924 let pixel = indicator_geometry(content, VIEWPORT, 0.0).unwrap();
925
926 let rows = [row(0, -200.0), row(3, 100.0)];
927 let mut thumb = ThumbLength::default();
928 let wear = scaling_list_geometry(&mut thumb, list(&rows)).unwrap();
929
930 assert!((pixel.thumb - INDICATOR_MIN_THUMB).abs() < 1e-6);
931 assert!((wear.thumb - 0.4).abs() < 1e-6, "{wear:?}");
932 }
933
934 #[test]
935 fn a_reported_row_is_not_the_row_as_it_is_drawn() {
936 let mut out = Vec::new();
940 let density = 2.0;
941 let viewport = 227.0;
942 for (height, carried) in [(51.5, 0.5), (52.0, 0.0)] {
943 scaling_list_items(viewport, density, [(20.0, height)], &mut out);
944 let drawn = crate::round_scaling_list::place_row(viewport, 20.0, height, density)
945 .expect("placed");
946 let item = out.first().expect("on screen");
947 assert!(
948 (item.start_offset - (drawn.top * density - carried - 227.0)).abs() < 1e-4,
949 "{height}dp: reported {} against drawn {}",
950 item.start_offset,
951 drawn.top * density
952 );
953 assert_eq!(item.size, (drawn.height * density).round());
956 }
957 }
958
959 #[test]
960 fn a_list_that_does_not_scale_its_rows_reports_them_at_full_height() {
961 let mut wear = Vec::new();
967 let mut still = Vec::new();
968 let rows = [(4.0, 52.0), (60.0, 52.0), (116.0, 52.0)];
969 scaling_list_items(227.0, 2.0, rows, &mut wear);
970 scaling_list_items_with(
971 ScalingParams::WEAR.reduced_motion(),
972 227.0,
973 2.0,
974 rows,
975 &mut still,
976 );
977 assert_eq!(wear.len(), still.len());
978 assert!(
979 wear[0].size < still[0].size,
980 "the top row shrinks under the Wear ramp and not under a stilled \
981 one: {} vs {}",
982 wear[0].size,
983 still[0].size
984 );
985 assert_eq!(still[0].size, 104.0, "52dp at density 2, unscaled");
986 let mut default = Vec::new();
988 scaling_list_items_with(ScalingParams::WEAR, 227.0, 2.0, rows, &mut default);
989 assert_eq!(default, wear);
990 }
991
992 #[test]
993 fn the_window_is_the_rows_that_still_meet_the_display() {
994 let mut out = Vec::new();
999 let rows: Vec<(f32, f32)> = (0..10)
1000 .map(|index| (index as f32 * 40.0 - 100.0, 40.0))
1001 .collect();
1002 scaling_list_items(227.0, 2.0, rows.iter().copied(), &mut out);
1003 let indices: Vec<usize> = out.iter().map(|item| item.index).collect();
1004 assert_eq!(indices, vec![2, 3, 4, 5, 6, 7, 8]);
1005 }
1006
1007 #[test]
1008 fn invalid_scaling_list_input_never_produces_a_non_finite_thumb() {
1009 let mut out = Vec::new();
1010 scaling_list_items(f32::NAN, 2.0, [(0.0, 40.0)], &mut out);
1011 assert!(out.is_empty());
1012 scaling_list_items(227.0, f32::NAN, [(0.0, 40.0)], &mut out);
1013 assert!(out.is_empty());
1014
1015 let rows = [
1016 IndicatorItem {
1017 index: 0,
1018 start_offset: f32::NAN,
1019 size: 0.0,
1020 },
1021 IndicatorItem {
1022 index: 3,
1023 start_offset: f32::INFINITY,
1024 size: -1.0,
1025 },
1026 ];
1027 let mut thumb = ThumbLength::default();
1028 let geometry = scaling_list_geometry(&mut thumb, list(&rows)).expect("a geometry");
1029 assert!(
1030 geometry.thumb.is_finite() && geometry.offset.is_finite(),
1031 "{geometry:?}"
1032 );
1033 assert!(geometry.thumb >= INDICATOR_MIN_THUMB && geometry.thumb <= INDICATOR_MAX_THUMB);
1034 assert!(geometry.offset >= 0.0 && geometry.offset <= 1.0);
1035 }
1036
1037 #[test]
1038 fn invalid_public_inputs_never_emit_non_finite_draw_values() {
1039 for radius in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -1.0] {
1040 let arc = indicator_arc(radius);
1041 assert_eq!(arc.sweep(), 0.0);
1042 assert_eq!(arc.segment_inset(), 0.0);
1043 }
1044
1045 let parts = indicator_segments(
1046 indicator_arc(LARGE_RADIUS_DP),
1047 IndicatorGeometry {
1048 thumb: f32::NAN,
1049 offset: f32::INFINITY,
1050 },
1051 f32::NAN,
1052 );
1053 for (_, part) in parts {
1054 match part {
1055 IndicatorSegment::Arc {
1056 start,
1057 sweep,
1058 alpha,
1059 } => assert!(start.is_finite() && sweep.is_finite() && alpha == 0.0),
1060 IndicatorSegment::Dot {
1061 angle,
1062 radius,
1063 alpha,
1064 } => assert!(angle.is_finite() && radius.is_finite() && alpha == 0.0),
1065 }
1066 }
1067 }
1068}