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,
59 width: f32,
60 half_sweep: f32,
61 segment_inset: f32,
62}
63
64impl IndicatorArc {
65 pub fn centreline(self) -> f32 {
67 self.centreline
68 }
69
70 pub fn width(self) -> f32 {
72 self.width
73 }
74
75 pub fn segment_inset(self) -> f32 {
77 self.segment_inset
78 }
79
80 pub fn start_angle(self) -> f32 {
83 -self.half_sweep
84 }
85
86 pub fn sweep(self) -> f32 {
88 self.half_sweep * 2.0
89 }
90
91 pub fn cap_sweep(self) -> f32 {
97 if self.centreline > 0.0 {
98 self.width / self.centreline
99 } else {
100 0.0
101 }
102 }
103}
104
105fn height_to_sweep(height: f32, radius: f32) -> f32 {
106 if radius <= 0.0 || !radius.is_finite() {
107 return 0.0;
108 }
109 (height * 0.5 / radius).clamp(-1.0, 1.0).asin() * 2.0
110}
111
112pub fn indicator_arc(radius: f32) -> IndicatorArc {
124 let width = indicator_width_dp(radius * 2.0);
125 let usable_radius = radius - INDICATOR_EDGE_PADDING_DP;
126 let centreline = usable_radius - width * 0.5;
127 if centreline <= 0.0 || !centreline.is_finite() {
128 return IndicatorArc {
129 centreline: 0.0,
130 width,
131 half_sweep: 0.0,
132 segment_inset: 0.0,
133 };
134 }
135 let segment_inset = height_to_sweep(width + INDICATOR_GAP_DP, usable_radius);
136 let half_sweep = ((height_to_sweep(INDICATOR_HEIGHT_DP, usable_radius) + segment_inset) * 0.5)
137 .min(FRAC_PI_2);
138 IndicatorArc {
139 centreline,
140 width,
141 half_sweep,
142 segment_inset,
143 }
144}
145
146#[derive(Clone, Copy, Debug, PartialEq)]
149pub struct IndicatorGeometry {
150 pub thumb: f32,
152 pub offset: f32,
154}
155
156pub fn indicator_geometry(content: f32, viewport: f32, scrolled: f32) -> Option<IndicatorGeometry> {
168 thumb_geometry(
169 content,
170 viewport,
171 scrolled,
172 ThumbBounds::new(INDICATOR_MIN_THUMB, INDICATOR_MAX_THUMB),
173 )
174 .map(|geometry| IndicatorGeometry {
175 thumb: geometry.length,
176 offset: geometry.offset,
177 })
178}
179
180#[derive(Clone, Copy, Debug, PartialEq)]
191pub struct IndicatorItem {
192 pub index: usize,
194 pub start_offset: f32,
197 pub size: f32,
202}
203
204#[derive(Clone, Copy, Debug, PartialEq)]
206pub struct ScalingList<'a> {
207 pub visible: &'a [IndicatorItem],
212 pub total: usize,
215 pub viewport: f32,
217 pub before_padding: f32,
221 pub after_padding: f32,
224}
225
226pub fn decimal_first_item_index(list: ScalingList<'_>) -> f32 {
233 let Some(first) = list.visible.first() else {
234 return 0.0;
235 };
236 let offset_from_start = if first.index == 0 {
237 list.before_padding
238 } else {
239 0.0
240 };
241 let start = first.start_offset - offset_from_start;
242 let top = -(list.viewport / 2.0);
243 let fraction = ((top - start) / (first.size + offset_from_start).max(1.0)).max(0.0);
244 finite(first.index as f32 + fraction)
245}
246
247pub fn decimal_last_item_index(list: ScalingList<'_>) -> f32 {
252 let Some(last) = list.visible.last() else {
253 return 0.0;
254 };
255 let span = last.size
256 + if last.index + 1 == list.total {
257 list.after_padding
258 } else {
259 0.0
260 };
261 let end = last.start_offset + span;
262 let bottom = list.viewport / 2.0;
263 let fraction = (1.0 - (end - bottom) / span.max(1.0)).min(1.0);
264 finite(last.index as f32 + fraction)
265}
266
267pub fn position_fraction(list: ScalingList<'_>) -> f32 {
275 if list.visible.is_empty() {
276 return 0.0;
277 }
278 let first = decimal_first_item_index(list);
279 let remaining = list.total as f32 - decimal_last_item_index(list);
280 if first + remaining == 0.0 {
281 0.0
282 } else {
283 finite(first / (first + remaining))
284 }
285}
286
287#[derive(Clone, Copy, Debug, Default, PartialEq)]
301pub struct ThumbLength {
302 fraction: f32,
303 items: usize,
304}
305
306impl ThumbLength {
307 pub fn of(&mut self, list: ScalingList<'_>) -> f32 {
309 if list.visible.is_empty() {
310 return 0.0;
311 }
312 if self.items != list.total {
313 self.items = list.total;
314 let span = decimal_last_item_index(list) - decimal_first_item_index(list);
315 let share = span / list.total.max(1) as f32;
316 self.fraction = if share.is_finite() {
317 share.clamp(INDICATOR_MIN_THUMB, INDICATOR_MAX_THUMB)
318 } else {
319 INDICATOR_MIN_THUMB
320 };
321 }
322 self.fraction
323 }
324
325 pub fn forget(&mut self) {
327 *self = Self::default();
328 }
329}
330
331pub fn scaling_list_geometry(
345 thumb: &mut ThumbLength,
346 list: ScalingList<'_>,
347) -> Option<IndicatorGeometry> {
348 if list.visible.is_empty() || list.total == 0 || !list.viewport.is_finite() {
349 return None;
350 }
351 let size = thumb.of(list);
352 let position = position_fraction(list).clamp(0.0, 1.0);
353 Some(IndicatorGeometry {
354 thumb: size,
355 offset: position * (1.0 - size),
356 })
357}
358
359pub fn scaling_list_items<I>(viewport: f32, density: f32, rows: I, out: &mut Vec<IndicatorItem>)
375where
376 I: IntoIterator<Item = (f32, f32)>,
377{
378 scaling_list_items_with(ScalingParams::WEAR, viewport, density, rows, out)
379}
380
381pub fn scaling_list_items_with<I>(
390 params: ScalingParams,
391 viewport: f32,
392 density: f32,
393 rows: I,
394 out: &mut Vec<IndicatorItem>,
395) where
396 I: IntoIterator<Item = (f32, f32)>,
397{
398 out.clear();
399 if !viewport.is_finite() || !density.is_finite() {
400 return;
401 }
402 let pixels = density > 0.0;
403 let to_px = |value: f32| if pixels { value * density } else { value };
404 let round_px = |value: f32| if pixels { value.round() } else { value };
405 let viewport_px = round_px(to_px(viewport));
406 let centre_line = if pixels {
407 (viewport_px * 0.5).floor()
408 } else {
409 viewport_px * 0.5
410 };
411 for (index, (top, height)) in rows.into_iter().enumerate() {
412 let Some(placed) =
413 crate::round_scaling_list::place_row_with(params, viewport, top, height, density)
414 else {
415 continue;
416 };
417 let height_px = round_px(to_px(height));
418 let size = round_px(height_px * placed.scale);
419 let drawn_top = to_px(placed.top);
420 let carried = if pixels { odd_pixel(height_px) } else { 0.0 };
421 let stacked_top = drawn_top - carried + if pixels { odd_pixel(size) } else { 0.0 };
422 if stacked_top > viewport_px || stacked_top + size < 0.0 {
423 if out.is_empty() {
424 continue;
425 }
426 break;
427 }
428 out.push(IndicatorItem {
429 index,
430 start_offset: drawn_top - carried - centre_line,
431 size,
432 });
433 }
434}
435
436fn odd_pixel(pixels: f32) -> f32 {
437 let half = pixels * 0.5;
438 half - half.floor()
439}
440
441fn finite(value: f32) -> f32 {
442 if value.is_finite() { value } else { 0.0 }
443}
444
445#[derive(Clone, Copy, Debug, PartialEq)]
451pub enum IndicatorSegment {
452 Arc { start: f32, sweep: f32, alpha: f32 },
455 Dot {
457 angle: f32,
459 radius: f32,
461 alpha: f32,
462 },
463}
464
465#[derive(Clone, Copy, Debug, PartialEq, Eq)]
468pub enum IndicatorPart {
469 Track,
470 Thumb,
471}
472
473pub fn indicator_segments(
482 arc: IndicatorArc,
483 geometry: IndicatorGeometry,
484 alpha: f32,
485) -> [(IndicatorPart, IndicatorSegment); 3] {
486 let alpha = if alpha.is_finite() {
487 alpha.clamp(0.0, 1.0)
488 } else {
489 0.0
490 };
491 let thumb = if geometry.thumb.is_finite() {
492 geometry.thumb.clamp(0.0, 1.0)
493 } else {
494 0.0
495 };
496 let offset = if geometry.offset.is_finite() {
497 geometry.offset.clamp(0.0, 1.0 - thumb)
498 } else {
499 0.0
500 };
501 let sweep = arc.sweep();
502 let top = arc.start_angle();
503 let thumb_start = top + sweep * offset;
504 let thumb_sweep = sweep * thumb;
505 let below_start = thumb_start + thumb_sweep;
506 [
507 (
508 IndicatorPart::Track,
509 segment(top, thumb_start - top, arc.width, arc.segment_inset, alpha),
510 ),
511 (
512 IndicatorPart::Thumb,
513 segment(
514 thumb_start,
515 thumb_sweep,
516 arc.width,
517 arc.segment_inset,
518 alpha,
519 ),
520 ),
521 (
522 IndicatorPart::Track,
523 segment(
524 below_start,
525 top + sweep - below_start,
526 arc.width,
527 arc.segment_inset,
528 alpha,
529 ),
530 ),
531 ]
532}
533
534fn segment(start: f32, sweep: f32, width: f32, inset: f32, alpha: f32) -> IndicatorSegment {
535 if sweep <= 0.0 || inset <= 0.0 {
536 return IndicatorSegment::Arc {
537 start,
538 sweep: 0.0,
539 alpha: 0.0,
540 };
541 }
542 if sweep < inset {
543 let fill = sweep / inset;
544 return IndicatorSegment::Dot {
545 angle: start + sweep * 0.5,
546 radius: width * 0.5 * fill,
547 alpha: alpha * fill,
548 };
549 }
550 IndicatorSegment::Arc {
551 start: start + inset * 0.5,
552 sweep: sweep - inset,
553 alpha,
554 }
555}
556
557#[cfg(test)]
558mod tests {
559 use super::*;
560
561 const LARGE_RADIUS_DP: f32 = 113.5;
562 const SMALL_RADIUS_DP: f32 = 96.0;
563
564 #[test]
565 fn stroke_width_switches_at_the_wear_large_screen_breakpoint() {
566 assert_eq!(indicator_width_dp(224.99), INDICATOR_NARROW_WIDTH_DP);
567 assert_eq!(indicator_width_dp(225.0), INDICATOR_WIDTH_DP);
568 assert_eq!(indicator_width_dp(f32::NAN), INDICATOR_NARROW_WIDTH_DP);
569 }
570
571 #[test]
572 fn the_track_lands_where_the_shipping_compose_build_draws_it() {
573 let large = indicator_arc(LARGE_RADIUS_DP);
574 assert!((large.centreline() - 108.5).abs() < 0.01, "{large:?}");
575 assert!((large.width() - 6.0).abs() < 0.01, "{large:?}");
576
577 let small = indicator_arc(SMALL_RADIUS_DP);
578 assert!((small.centreline() - 91.5).abs() < 0.01, "{small:?}");
579 assert!((small.width() - 5.0).abs() < 0.01, "{small:?}");
580 }
581
582 #[test]
583 fn the_sweep_is_a_height_in_dp_not_a_fixed_angle() {
584 let large = indicator_arc(LARGE_RADIUS_DP).sweep().to_degrees();
585 let small = indicator_arc(SMALL_RADIUS_DP).sweep().to_degrees();
586 assert!((large - 30.54).abs() < 0.05, "{large}");
587 assert!((small - 35.73).abs() < 0.05, "{small}");
588 assert!(small > large);
589 }
590
591 #[test]
592 fn a_list_that_fits_on_screen_shows_no_indicator_at_all() {
593 assert_eq!(indicator_geometry(100.0, 100.0, 0.0), None);
594 assert_eq!(indicator_geometry(80.0, 100.0, 0.0), None);
595 assert_eq!(indicator_geometry(f32::NAN, 100.0, 0.0), None);
596 assert_eq!(indicator_geometry(200.0, 0.0, 0.0), None);
597 }
598
599 #[test]
600 fn the_thumb_is_the_viewport_share_clamped_at_both_ends() {
601 let half = indicator_geometry(200.0, 100.0, 0.0).unwrap();
602 assert!((half.thumb - 0.5).abs() < 1e-6, "{half:?}");
603 let long = indicator_geometry(10_000.0, 100.0, 0.0).unwrap();
604 assert!((long.thumb - INDICATOR_MIN_THUMB).abs() < 1e-6, "{long:?}");
605 let short = indicator_geometry(105.0, 100.0, 0.0).unwrap();
606 assert!(
607 (short.thumb - INDICATOR_MAX_THUMB).abs() < 1e-6,
608 "{short:?}"
609 );
610 }
611
612 #[test]
613 fn the_thumb_reaches_the_bottom_of_the_track_and_no_further() {
614 let bottom = indicator_geometry(200.0, 100.0, 100.0).unwrap();
615 assert!(
616 (bottom.offset + bottom.thumb - 1.0).abs() < 1e-6,
617 "{bottom:?}"
618 );
619 let past = indicator_geometry(200.0, 100.0, 500.0).unwrap();
620 assert_eq!(past, bottom);
621 }
622
623 #[test]
624 fn the_indicator_is_three_segments_with_a_gap_either_side_of_the_thumb() {
625 let arc = indicator_arc(LARGE_RADIUS_DP);
626 let geometry = IndicatorGeometry {
627 thumb: 0.4,
628 offset: 0.3,
629 };
630 let parts = indicator_segments(arc, geometry, 1.0);
631 assert_eq!(parts[0].0, IndicatorPart::Track);
632 assert_eq!(parts[1].0, IndicatorPart::Thumb);
633 assert_eq!(parts[2].0, IndicatorPart::Track);
634
635 let ink_bounds = |segment: IndicatorSegment| match segment {
636 IndicatorSegment::Arc { start, sweep, .. } => {
637 (start - arc.cap_sweep() * 0.5, sweep + arc.cap_sweep())
638 }
639 other => panic!("expected an arc, got {other:?}"),
640 };
641 let (above_start, above_sweep) = ink_bounds(parts[0].1);
642 let (thumb_start, thumb_sweep) = ink_bounds(parts[1].1);
643 let (below_start, below_sweep) = ink_bounds(parts[2].1);
644 let gap = arc.segment_inset() - arc.cap_sweep();
645
646 assert!((above_start - arc.start_angle() - gap * 0.5).abs() < 1e-4);
647 assert!((thumb_start - (above_start + above_sweep) - gap).abs() < 1e-4);
648 assert!((below_start - (thumb_start + thumb_sweep) - gap).abs() < 1e-4);
649 assert!(
650 (below_start + below_sweep + gap * 0.5 - (arc.start_angle() + arc.sweep())).abs()
651 < 1e-4,
652 "the track has to end where it should"
653 );
654 }
655
656 #[test]
657 fn a_segment_shorter_than_its_stroke_becomes_a_shrinking_dot() {
658 let arc = indicator_arc(LARGE_RADIUS_DP);
659 let parts = indicator_segments(
660 arc,
661 IndicatorGeometry {
662 thumb: 0.7,
663 offset: 0.0,
664 },
665 1.0,
666 );
667 match parts[0].1 {
668 IndicatorSegment::Dot { radius, alpha, .. } => {
669 assert!(
670 radius <= arc.width() * 0.5,
671 "a dot never exceeds the stroke"
672 );
673 assert!(alpha < 1.0, "it fades on the same fraction as it shrinks");
674 }
675 IndicatorSegment::Arc { sweep, .. } => {
676 assert!(sweep <= 0.0, "an arc this short should have been a dot");
677 }
678 }
679 }
680
681 #[test]
682 fn fading_the_indicator_fades_every_piece_of_it() {
683 let arc = indicator_arc(LARGE_RADIUS_DP);
684 let geometry = IndicatorGeometry {
685 thumb: 0.4,
686 offset: 0.3,
687 };
688 for (_, segment) in indicator_segments(arc, geometry, 0.25) {
689 let alpha = match segment {
690 IndicatorSegment::Arc { alpha, .. } => alpha,
691 IndicatorSegment::Dot { alpha, .. } => alpha,
692 };
693 assert!(alpha <= 0.25 + 1e-6, "{segment:?}");
694 }
695 }
696
697 #[test]
698 fn a_display_too_small_to_hold_the_track_degrades_instead_of_panicking() {
699 let tiny = indicator_arc(1.0);
700 assert_eq!(tiny.centreline(), 0.0);
701 assert_eq!(tiny.sweep(), 0.0);
702 assert_eq!(tiny.cap_sweep(), 0.0);
703 let parts = indicator_segments(
704 tiny,
705 IndicatorGeometry {
706 thumb: 0.4,
707 offset: 0.3,
708 },
709 1.0,
710 );
711 for (_, segment) in parts {
712 assert!(matches!(segment, IndicatorSegment::Arc { sweep: 0.0, .. }));
713 }
714 }
715
716 const VIEWPORT: f32 = 400.0;
717
718 fn list<'a>(visible: &'a [IndicatorItem]) -> ScalingList<'a> {
719 ScalingList {
720 visible,
721 total: 10,
722 viewport: VIEWPORT,
723 before_padding: 0.0,
724 after_padding: 0.0,
725 }
726 }
727
728 fn row(index: usize, start_offset: f32) -> IndicatorItem {
729 IndicatorItem {
730 index,
731 start_offset,
732 size: 100.0,
733 }
734 }
735
736 #[test]
737 fn a_row_flush_with_the_top_of_the_screen_is_a_whole_index() {
738 let rows = [row(3, -200.0), row(6, 100.0)];
739 assert_eq!(decimal_first_item_index(list(&rows)), 3.0);
740 }
741
742 #[test]
743 fn a_row_half_off_the_top_reads_half_an_index() {
744 let rows = [row(3, -250.0), row(6, 100.0)];
745 assert_eq!(decimal_first_item_index(list(&rows)), 3.5);
746 }
747
748 #[test]
749 fn the_last_index_counts_how_much_of_the_row_is_on_screen() {
750 let rows = [row(3, -200.0), row(6, 150.0)];
751 assert_eq!(decimal_last_item_index(list(&rows)), 6.5);
752 }
753
754 #[test]
755 fn the_padding_outside_the_list_counts_only_at_the_end_it_belongs_to() {
756 let rows = [row(0, -250.0), row(9, 150.0)];
757 let padded = ScalingList {
758 before_padding: 80.0,
759 after_padding: 60.0,
760 ..list(&rows)
761 };
762 assert!((decimal_first_item_index(padded) - 130.0 / 180.0).abs() < 1e-6);
763 assert!((decimal_last_item_index(padded) - (9.0 + 0.3125)).abs() < 1e-6);
764
765 let inner = [row(3, -250.0), row(6, 150.0)];
766 let inner = ScalingList {
767 before_padding: 80.0,
768 after_padding: 60.0,
769 ..list(&inner)
770 };
771 assert_eq!(decimal_first_item_index(inner), 3.5);
772 assert_eq!(decimal_last_item_index(inner), 6.5);
773 }
774
775 #[test]
776 fn the_thumb_is_the_share_of_the_items_on_screen_not_of_the_pixels() {
777 let rows = [row(3, -250.0), row(8, 150.0)];
778 let mut thumb = ThumbLength::default();
779 assert!((thumb.of(list(&rows)) - 0.5).abs() < 1e-6);
780 }
781
782 #[test]
783 fn the_thumb_is_clamped_at_both_ends_however_long_the_list_is() {
784 let rows = [row(3, -250.0), row(4, 150.0)];
785 let mut short = ThumbLength::default();
786 assert_eq!(short.of(list(&rows)), INDICATOR_MIN_THUMB);
787
788 let rows = [row(0, -250.0), row(9, 150.0)];
789 let mut long = ThumbLength::default();
790 assert_eq!(long.of(list(&rows)), INDICATOR_MAX_THUMB);
791 }
792
793 #[test]
794 fn the_thumb_is_measured_once_and_then_only_when_the_list_changes_length() {
795 let mut thumb = ThumbLength::default();
796 let five = [row(3, -250.0), row(8, 150.0)];
797 assert!((thumb.of(list(&five)) - 0.5).abs() < 1e-6);
798
799 let three = [row(3, -250.0), row(6, 150.0)];
800 assert!(
801 (thumb.of(list(&three)) - 0.5).abs() < 1e-6,
802 "the window shrank but the list did not, so the thumb holds"
803 );
804
805 let longer = ScalingList {
806 total: 20,
807 ..list(&three)
808 };
809 assert_eq!(thumb.of(longer), INDICATOR_MIN_THUMB);
810
811 thumb.forget();
812 assert!((thumb.of(list(&five)) - 0.5).abs() < 1e-6);
813 }
814
815 #[test]
816 fn the_position_is_how_many_items_are_left_not_how_far_the_pixels_went() {
817 let rows = [row(3, -250.0), row(6, 150.0)];
818 assert!((position_fraction(list(&rows)) - 0.5).abs() < 1e-6);
819 }
820
821 #[test]
822 fn a_list_at_the_top_puts_the_thumb_at_the_top_and_one_at_the_end_at_the_end() {
823 let mut thumb = ThumbLength::default();
824 let top = [row(0, -200.0), row(3, 150.0)];
825 let geometry = scaling_list_geometry(&mut thumb, list(&top)).unwrap();
826 assert_eq!(geometry.offset, 0.0);
827
828 let mut thumb = ThumbLength::default();
829 let end = [row(6, -250.0), row(9, 100.0)];
830 let geometry = scaling_list_geometry(&mut thumb, list(&end)).unwrap();
831 assert_eq!(decimal_last_item_index(list(&end)), 10.0);
832 assert!((geometry.offset + geometry.thumb - 1.0).abs() < 1e-6);
833 }
834
835 #[test]
836 fn a_list_with_nothing_on_screen_has_no_indicator() {
837 let mut thumb = ThumbLength::default();
838 assert_eq!(scaling_list_geometry(&mut thumb, list(&[])), None);
839 let rows = [row(3, -250.0)];
840 let empty = ScalingList {
841 total: 0,
842 ..list(&rows)
843 };
844 assert_eq!(scaling_list_geometry(&mut thumb, empty), None);
845 }
846
847 #[test]
848 fn the_two_models_disagree_the_moment_the_rows_are_not_all_the_same_height() {
849 let heights: Vec<f32> = std::iter::once(600.0).chain([100.0; 9]).collect();
850 let content: f32 = heights.iter().sum();
851 let pixel = indicator_geometry(content, VIEWPORT, 0.0).unwrap();
852
853 let rows = [row(0, -200.0), row(3, 100.0)];
854 let mut thumb = ThumbLength::default();
855 let wear = scaling_list_geometry(&mut thumb, list(&rows)).unwrap();
856
857 assert!((pixel.thumb - INDICATOR_MIN_THUMB).abs() < 1e-6);
858 assert!((wear.thumb - 0.4).abs() < 1e-6, "{wear:?}");
859 }
860
861 #[test]
862 fn a_reported_row_is_not_the_row_as_it_is_drawn() {
863 let mut out = Vec::new();
864 let density = 2.0;
865 let viewport = 227.0;
866 for (height, carried) in [(51.5, 0.5), (52.0, 0.0)] {
867 scaling_list_items(viewport, density, [(20.0, height)], &mut out);
868 let drawn = crate::round_scaling_list::place_row(viewport, 20.0, height, density)
869 .expect("placed");
870 let item = out.first().expect("on screen");
871 assert!(
872 (item.start_offset - (drawn.top * density - carried - 227.0)).abs() < 1e-4,
873 "{height}dp: reported {} against drawn {}",
874 item.start_offset,
875 drawn.top * density
876 );
877 assert_eq!(item.size, (drawn.height * density).round());
878 }
879 }
880
881 #[test]
882 fn a_list_that_does_not_scale_its_rows_reports_them_at_full_height() {
883 let mut wear = Vec::new();
884 let mut still = Vec::new();
885 let rows = [(4.0, 52.0), (60.0, 52.0), (116.0, 52.0)];
886 scaling_list_items(227.0, 2.0, rows, &mut wear);
887 scaling_list_items_with(
888 ScalingParams::WEAR.reduced_motion(),
889 227.0,
890 2.0,
891 rows,
892 &mut still,
893 );
894 assert_eq!(wear.len(), still.len());
895 assert!(
896 wear[0].size < still[0].size,
897 "the top row shrinks under the Wear ramp and not under a stilled \
898 one: {} vs {}",
899 wear[0].size,
900 still[0].size
901 );
902 assert_eq!(still[0].size, 104.0, "52dp at density 2, unscaled");
903 let mut default = Vec::new();
904 scaling_list_items_with(ScalingParams::WEAR, 227.0, 2.0, rows, &mut default);
905 assert_eq!(default, wear);
906 }
907
908 #[test]
909 fn the_window_is_the_rows_that_still_meet_the_display() {
910 let mut out = Vec::new();
911 let rows: Vec<(f32, f32)> = (0..10)
912 .map(|index| (index as f32 * 40.0 - 100.0, 40.0))
913 .collect();
914 scaling_list_items(227.0, 2.0, rows.iter().copied(), &mut out);
915 let indices: Vec<usize> = out.iter().map(|item| item.index).collect();
916 assert_eq!(indices, vec![2, 3, 4, 5, 6, 7, 8]);
917 }
918
919 #[test]
920 fn invalid_scaling_list_input_never_produces_a_non_finite_thumb() {
921 let mut out = Vec::new();
922 scaling_list_items(f32::NAN, 2.0, [(0.0, 40.0)], &mut out);
923 assert!(out.is_empty());
924 scaling_list_items(227.0, f32::NAN, [(0.0, 40.0)], &mut out);
925 assert!(out.is_empty());
926
927 let rows = [
928 IndicatorItem {
929 index: 0,
930 start_offset: f32::NAN,
931 size: 0.0,
932 },
933 IndicatorItem {
934 index: 3,
935 start_offset: f32::INFINITY,
936 size: -1.0,
937 },
938 ];
939 let mut thumb = ThumbLength::default();
940 let geometry = scaling_list_geometry(&mut thumb, list(&rows)).expect("a geometry");
941 assert!(
942 geometry.thumb.is_finite() && geometry.offset.is_finite(),
943 "{geometry:?}"
944 );
945 assert!(geometry.thumb >= INDICATOR_MIN_THUMB && geometry.thumb <= INDICATOR_MAX_THUMB);
946 assert!(geometry.offset >= 0.0 && geometry.offset <= 1.0);
947 }
948
949 #[test]
950 fn invalid_public_inputs_never_emit_non_finite_draw_values() {
951 for radius in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -1.0] {
952 let arc = indicator_arc(radius);
953 assert_eq!(arc.sweep(), 0.0);
954 assert_eq!(arc.segment_inset(), 0.0);
955 }
956
957 let parts = indicator_segments(
958 indicator_arc(LARGE_RADIUS_DP),
959 IndicatorGeometry {
960 thumb: f32::NAN,
961 offset: f32::INFINITY,
962 },
963 f32::NAN,
964 );
965 for (_, part) in parts {
966 match part {
967 IndicatorSegment::Arc {
968 start,
969 sweep,
970 alpha,
971 } => assert!(start.is_finite() && sweep.is_finite() && alpha == 0.0),
972 IndicatorSegment::Dot {
973 angle,
974 radius,
975 alpha,
976 } => assert!(angle.is_finite() && radius.is_finite() && alpha == 0.0),
977 }
978 }
979 }
980}