1pub const EDGE_SCALE: f32 = 0.7;
62pub const EDGE_ALPHA: f32 = 0.5;
63pub const MIN_ELEMENT_HEIGHT: f32 = 0.2;
67pub const MAX_ELEMENT_HEIGHT: f32 = 0.6;
68pub const MIN_TRANSITION_AREA: f32 = 0.35;
69pub const MAX_TRANSITION_AREA: f32 = 0.55;
70
71#[derive(Clone, Copy, Debug, PartialEq)]
78pub struct ScalingParams {
79 pub edge_scale: f32,
80 pub edge_alpha: f32,
81 pub min_element_height: f32,
82 pub max_element_height: f32,
83 pub min_transition_area: f32,
84 pub max_transition_area: f32,
85}
86
87impl Default for ScalingParams {
88 fn default() -> Self {
89 Self::WEAR
90 }
91}
92
93impl ScalingParams {
94 pub const WEAR: Self = Self {
96 edge_scale: EDGE_SCALE,
97 edge_alpha: EDGE_ALPHA,
98 min_element_height: MIN_ELEMENT_HEIGHT,
99 max_element_height: MAX_ELEMENT_HEIGHT,
100 min_transition_area: MIN_TRANSITION_AREA,
101 max_transition_area: MAX_TRANSITION_AREA,
102 };
103
104 pub const fn reduced_motion(self) -> Self {
108 Self {
109 edge_scale: 1.0,
110 edge_alpha: 1.0,
111 min_element_height: self.min_element_height,
112 max_element_height: self.max_element_height,
113 min_transition_area: self.min_transition_area,
114 max_transition_area: self.max_transition_area,
115 }
116 }
117}
118
119#[derive(Clone, Copy, Debug, PartialEq)]
121pub struct ScaleAlpha {
122 pub scale: f32,
123 pub alpha: f32,
124}
125
126impl ScaleAlpha {
127 pub const UNCHANGED: Self = Self {
129 scale: 1.0,
130 alpha: 1.0,
131 };
132}
133
134pub fn scale_and_alpha(viewport: f32, top: f32, bottom: f32) -> Option<ScaleAlpha> {
142 scale_and_alpha_with(ScalingParams::WEAR, viewport, top, bottom)
143}
144
145pub fn scale_and_alpha_with(
147 params: ScalingParams,
148 viewport: f32,
149 top: f32,
150 bottom: f32,
151) -> Option<ScaleAlpha> {
152 if !viewport.is_finite() || !top.is_finite() || !bottom.is_finite() || bottom < top {
153 return None;
154 }
155 if viewport <= 0.0 {
156 return Some(ScaleAlpha::UNCHANGED);
157 }
158 let edge = (viewport - top).min(bottom) / viewport;
159 let size_ratio = inverse_lerp(
160 params.min_element_height,
161 params.max_element_height,
162 (bottom - top) / viewport,
163 );
164 let line = params.min_transition_area
165 + (params.max_transition_area - params.min_transition_area) * size_ratio;
166 if edge >= line || line <= 0.0 {
167 return Some(ScaleAlpha::UNCHANGED);
168 }
169 let progress = ease(1.0 - edge / line);
170 Some(ScaleAlpha {
171 scale: 1.0 + (params.edge_scale - 1.0) * progress,
172 alpha: 1.0 + (params.edge_alpha - 1.0) * progress,
173 })
174}
175
176#[derive(Clone, Copy, Debug, PartialEq)]
178pub struct PlacedRow {
179 pub top: f32,
181 pub height: f32,
183 pub reported_height: f32,
193 pub scale: f32,
194 pub alpha: f32,
195}
196
197pub fn place_row(viewport: f32, top: f32, height: f32, density: f32) -> Option<PlacedRow> {
217 place_row_with(ScalingParams::WEAR, viewport, top, height, density)
218}
219
220pub fn place_row_with(
222 params: ScalingParams,
223 viewport: f32,
224 top: f32,
225 height: f32,
226 density: f32,
227) -> Option<PlacedRow> {
228 if !height.is_finite() || height < 0.0 || !density.is_finite() {
229 return None;
230 }
231 if density <= 0.0 {
232 let transform = scale_and_alpha_with(params, viewport, top, top + height)?;
233 let scaled = height * transform.scale;
234 return Some(PlacedRow {
235 top,
236 height: scaled,
237 reported_height: scaled,
238 scale: transform.scale,
239 alpha: transform.alpha,
240 });
241 }
242 let viewport_px = (viewport * density).round();
243 let top_px = (top * density).round();
244 let height_px = (height * density).round();
245 let transform = scale_and_alpha_with(params, viewport_px, top_px, top_px + height_px)?;
246 let scaled_px = (height_px * transform.scale).round();
247 let above = top_px + top_px + height_px < viewport_px;
248 let pinned = if above {
249 top_px + height_px - scaled_px
250 } else {
251 top_px
252 };
253 Some(PlacedRow {
254 top: (pinned + odd_pixel(height_px) - odd_pixel(scaled_px)) / density,
255 height: height_px * transform.scale / density,
256 reported_height: scaled_px / density,
257 scale: transform.scale,
258 alpha: transform.alpha,
259 })
260}
261
262#[derive(Clone, Copy, Debug, PartialEq)]
267pub struct RowRun {
268 pub viewport: f32,
270 pub anchor: usize,
272 pub anchor_top: f32,
276 pub gap: f32,
278 pub density: f32,
280}
281
282pub fn place_rows_with(
293 params: ScalingParams,
294 run: RowRun,
295 heights: &[f32],
296 out: &mut Vec<PlacedRow>,
297) {
298 out.clear();
299 if heights.is_empty() {
300 return;
301 }
302 let anchor = run.anchor.min(heights.len() - 1);
303 let unscaled = |top: f32, height: f32| PlacedRow {
304 top,
305 height,
306 reported_height: height,
307 scale: 1.0,
308 alpha: 1.0,
309 };
310 out.resize(heights.len(), unscaled(0.0, 0.0));
311 let place = |top: f32, height: f32| {
312 place_row_with(params, run.viewport, top, height, run.density)
313 .unwrap_or_else(|| unscaled(top, height))
314 };
315
316 let mut cursor = run.anchor_top;
317 for (index, &height) in heights.iter().enumerate().skip(anchor) {
318 let row = place(cursor, height);
319 cursor += height + run.gap;
320 out[index] = row;
321 }
322 let mut bottom = run.anchor_top;
323 for index in (0..anchor).rev() {
324 let height = heights[index];
325 bottom -= run.gap;
326 let row = place(bottom - height, height);
327 bottom -= height;
328 out[index] = row;
329 }
330}
331
332pub fn place_rows(run: RowRun, heights: &[f32], out: &mut Vec<PlacedRow>) {
334 place_rows_with(ScalingParams::WEAR, run, heights, out)
335}
336
337#[derive(Clone, Copy, Debug, PartialEq)]
344pub struct Slot {
345 pub top: f32,
346 pub height: f32,
347}
348
349impl Slot {
350 pub fn centre(self) -> f32 {
351 self.top + self.height * 0.5
352 }
353
354 pub fn bottom(self) -> f32 {
355 self.top + self.height
356 }
357}
358
359pub fn stack_into(heights: impl IntoIterator<Item = f32>, gap: f32, out: &mut Vec<Slot>) {
365 out.clear();
366 let mut cursor = 0.0;
367 for height in heights {
368 out.push(Slot {
369 top: cursor,
370 height,
371 });
372 cursor += height + gap;
373 }
374}
375
376#[derive(Clone, Copy, Debug, PartialEq)]
384pub struct CentreAnchor {
385 pub index: usize,
386 pub offset: f32,
387}
388
389impl Default for CentreAnchor {
390 fn default() -> Self {
391 Self {
392 index: 1,
393 offset: 0.0,
394 }
395 }
396}
397
398pub fn round_to_px(value: f32, density: f32) -> f32 {
406 if density <= 0.0 || !density.is_finite() || !value.is_finite() {
407 return value;
408 }
409 (value * density + 0.5).floor() / density
410}
411
412pub fn centre_offset(slots: &[Slot], viewport: f32, anchor: CentreAnchor, density: f32) -> f32 {
427 let Some(slot) = slots.get(anchor.index).or_else(|| slots.last()) else {
428 return 0.0;
429 };
430 round_to_px(viewport * 0.5 - slot.centre() - anchor.offset, density)
431}
432
433pub fn centre_offset_at(slots: &[Slot], viewport: f32, scroll: f32, density: f32) -> f32 {
441 if slots.is_empty() {
442 return 0.0;
443 }
444 let scroll = if scroll.is_finite() { scroll } else { 0.0 };
445 let whole = (scroll.floor().max(0.0) as usize).min(slots.len() - 1);
446 let fraction = (scroll - whole as f32).clamp(0.0, 1.0);
447 let mut anchor = slots[whole].centre();
448 if let Some(next) = slots.get(whole + 1) {
449 anchor += (next.centre() - anchor) * fraction;
450 }
451 round_to_px(viewport * 0.5 - anchor, density)
452}
453
454pub fn shift(slots: &mut [Slot], offset: f32) {
456 for slot in slots.iter_mut() {
457 slot.top += offset;
458 }
459}
460
461pub fn auto_centring_spacers(slots: &[Slot], viewport_px: f32, anchor: CentreAnchor) -> (f32, f32) {
477 let leading = slots
478 .get(anchor.index)
479 .or_else(|| slots.last())
480 .map(|slot| leading_auto_centring_spacer(viewport_px, slot.centre(), anchor.offset))
481 .unwrap_or(0.0);
482 let trailing = slots
483 .last()
484 .map(|slot| trailing_auto_centring_spacer(viewport_px, slot.height))
485 .unwrap_or(0.0);
486 (leading, trailing)
487}
488
489pub fn leading_auto_centring_spacer(
497 viewport_px: f32,
498 anchor_centre_px: f32,
499 anchor_offset: f32,
500) -> f32 {
501 ((viewport_px * 0.5).floor() - anchor_offset - anchor_centre_px).max(0.0)
502}
503
504pub fn trailing_auto_centring_spacer(viewport_px: f32, last_height_px: f32) -> f32 {
507 (viewport_px - (viewport_px * 0.5).floor() - last_height_px * 0.5).max(0.0)
508}
509
510pub fn anchor_travel(
535 anchor_centre: f32,
536 last_centre: f32,
537 before_padding: f32,
538 after_padding: f32,
539) -> (f32, f32) {
540 let end = last_centre + after_padding;
541 let start = anchor_centre - before_padding;
542 (start.min(end), end)
543}
544
545fn odd_pixel(pixels: f32) -> f32 {
546 let half = pixels * 0.5;
547 half - half.floor()
548}
549
550fn inverse_lerp(start: f32, stop: f32, value: f32) -> f32 {
551 ((value - start) / (stop - start)).clamp(0.0, 1.0)
552}
553
554fn ease(x: f32) -> f32 {
555 let x = x.clamp(0.0, 1.0);
556 let mut low = 0.0f32;
557 let mut high = 1.0f32;
558 let mut t = x;
559 for _ in 0..12 {
560 let value = bezier(t, 0.3, 0.7);
561 if value < x {
562 low = t;
563 } else {
564 high = t;
565 }
566 t = (low + high) * 0.5;
567 }
568 bezier(t, 0.0, 1.0)
569}
570
571fn bezier(t: f32, first: f32, second: f32) -> f32 {
572 let inverse = 1.0 - t;
573 3.0 * inverse * inverse * t * first + 3.0 * inverse * t * t * second + t * t * t
574}
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579
580 const VIEWPORT: f32 = 227.0;
581
582 #[test]
583 fn a_row_in_the_middle_is_left_alone() {
584 let middle = scale_and_alpha(VIEWPORT, VIEWPORT * 0.45, VIEWPORT * 0.55).unwrap();
585 assert_eq!(middle, ScaleAlpha::UNCHANGED);
586 }
587
588 #[test]
589 fn a_row_at_the_edge_is_shrunk_and_faded_together() {
590 let edge = scale_and_alpha(VIEWPORT, 0.0, 20.0).unwrap();
591 assert!(edge.scale < 1.0 && edge.scale >= EDGE_SCALE, "{edge:?}");
592 assert!(edge.alpha < 1.0 && edge.alpha >= EDGE_ALPHA, "{edge:?}");
593 let top = scale_and_alpha(VIEWPORT, 0.0, 0.0).unwrap();
594 assert!((top.scale - EDGE_SCALE).abs() < 1e-3, "{top:?}");
595 assert!((top.alpha - EDGE_ALPHA).abs() < 1e-3, "{top:?}");
596 }
597
598 #[test]
599 fn the_two_edges_treat_a_row_the_same() {
600 let height = 40.0;
601 let near_top = scale_and_alpha(VIEWPORT, 8.0, 8.0 + height).unwrap();
602 let near_bottom =
603 scale_and_alpha(VIEWPORT, VIEWPORT - 8.0 - height, VIEWPORT - 8.0).unwrap();
604 assert!((near_top.scale - near_bottom.scale).abs() < 1e-5);
605 assert!((near_top.alpha - near_bottom.alpha).abs() < 1e-5);
606 }
607
608 #[test]
609 fn a_taller_row_starts_shrinking_further_from_the_edge() {
610 let top = VIEWPORT - 10.0;
611 let short = scale_and_alpha(VIEWPORT, top, top + VIEWPORT * 0.2).unwrap();
612 let tall = scale_and_alpha(VIEWPORT, top, top + VIEWPORT * 0.62).unwrap();
613 assert!(tall.scale < short.scale, "short {short:?} tall {tall:?}");
614 }
615
616 #[test]
617 fn a_row_is_placed_from_the_full_heights_above_it_not_the_scaled_ones() {
618 let first = place_row(VIEWPORT, 0.0, 50.0, 2.0).unwrap();
619 let second = place_row(VIEWPORT, 50.0, 50.0, 2.0).unwrap();
620 assert!(first.scale < 1.0, "the first row is at the edge: {first:?}");
621 assert!(second.top >= 49.0, "{second:?}");
622 }
623
624 #[test]
625 fn a_row_above_the_centre_line_keeps_its_bottom_edge() {
626 let above = place_row(VIEWPORT, 4.0, 50.0, 2.0).unwrap();
627 assert!(above.scale < 1.0, "{above:?}");
628 assert!(
629 above.top > 4.0,
630 "shrinking should pull the top down: {above:?}"
631 );
632
633 let below = place_row(VIEWPORT, VIEWPORT - 54.0, 50.0, 2.0).unwrap();
634 assert!(below.scale < 1.0, "{below:?}");
635 assert!(
636 (below.top - (VIEWPORT - 54.0)).abs() < 0.6,
637 "the top is pinned below the line: {below:?}"
638 );
639 }
640
641 #[test]
642 fn an_odd_pixel_height_carries_the_half_pixel_composes_integer_halving_leaves() {
643 assert_eq!(odd_pixel(50.0), 0.0);
644 assert_eq!(odd_pixel(51.0), 0.5);
645 let odd = place_row(VIEWPORT, 3.0, 25.5, 2.0).unwrap();
646 assert!(odd.scale < 1.0, "needs to be in the scaled band: {odd:?}");
647 }
648
649 #[test]
650 fn a_density_of_zero_falls_back_to_continuous_placement_instead_of_dividing_by_it() {
651 let placed = place_row(VIEWPORT, 10.0, 50.0, 0.0).unwrap();
652 assert!(
653 placed.top.is_finite() && placed.height.is_finite(),
654 "{placed:?}"
655 );
656 assert_eq!(placed.top, 10.0);
657 let negative = place_row(VIEWPORT, 10.0, 50.0, -2.0).unwrap();
658 assert_eq!(negative, placed, "a nonsense density is not a crash");
659 }
660
661 #[test]
662 fn an_empty_viewport_leaves_everything_alone_rather_than_dividing_by_it() {
663 assert_eq!(scale_and_alpha(0.0, 0.0, 10.0), Some(ScaleAlpha::UNCHANGED));
664 assert_eq!(
665 scale_and_alpha(-5.0, 0.0, 10.0),
666 Some(ScaleAlpha::UNCHANGED)
667 );
668 }
669
670 #[test]
671 fn invalid_geometry_is_rejected_instead_of_producing_nan() {
672 assert_eq!(scale_and_alpha(f32::NAN, 0.0, 10.0), None);
673 assert_eq!(scale_and_alpha(VIEWPORT, 10.0, 9.0), None);
674 assert_eq!(place_row(VIEWPORT, 0.0, -1.0, 2.0), None);
675 assert_eq!(place_row(VIEWPORT, 0.0, 10.0, f32::INFINITY), None);
676 }
677
678 #[test]
679 fn the_easing_is_monotonic_and_spans_the_whole_range() {
680 assert!((ease(0.0) - 0.0).abs() < 1e-3, "{}", ease(0.0));
681 assert!((ease(1.0) - 1.0).abs() < 1e-3, "{}", ease(1.0));
682 let mut previous = -1.0;
683 for step in 0..=20 {
684 let value = ease(step as f32 / 20.0);
685 assert!(value >= previous - 1e-4, "not monotonic at {step}");
686 previous = value;
687 }
688 }
689
690 #[test]
691 fn the_easing_matches_the_current_wear_compose_curve() {
692 assert!((ease(0.25) - 0.166_779).abs() < 1e-3, "{}", ease(0.25));
693 }
694
695 #[test]
696 fn the_default_scaling_params_are_the_constants_the_module_documents() {
697 let params = ScalingParams::default();
698 assert_eq!(params.edge_scale, EDGE_SCALE);
699 assert_eq!(params.edge_alpha, EDGE_ALPHA);
700 assert_eq!(params.min_element_height, MIN_ELEMENT_HEIGHT);
701 assert_eq!(params.max_element_height, MAX_ELEMENT_HEIGHT);
702 assert_eq!(params.min_transition_area, MIN_TRANSITION_AREA);
703 assert_eq!(params.max_transition_area, MAX_TRANSITION_AREA);
704 assert_eq!(
705 scale_and_alpha_with(params, VIEWPORT, 0.0, 20.0),
706 scale_and_alpha(VIEWPORT, 0.0, 20.0)
707 );
708 assert_eq!(
709 place_row_with(params, VIEWPORT, 4.0, 50.0, 2.0),
710 place_row(VIEWPORT, 4.0, 50.0, 2.0)
711 );
712 }
713
714 #[test]
715 fn reduced_motion_turns_the_ramp_off_rather_than_damping_it() {
716 let params = ScalingParams::default().reduced_motion();
717 let edge = scale_and_alpha_with(params, VIEWPORT, 0.0, 0.0).unwrap();
718 assert_eq!(edge, ScaleAlpha::UNCHANGED);
719 }
720
721 #[test]
722 fn the_supplied_params_reach_the_pixel_path_and_not_only_the_continuous_one() {
723 let params = ScalingParams::default().reduced_motion();
724 let still = place_row_with(params, VIEWPORT, 4.0, 50.0, 2.0).unwrap();
725 assert_eq!(still.scale, 1.0, "{still:?}");
726 assert_eq!(still.alpha, 1.0, "{still:?}");
727 assert_eq!(still.top, 4.0, "an unscaled row is not pinned anywhere");
728 assert!(place_row(VIEWPORT, 4.0, 50.0, 2.0).unwrap().scale < 1.0);
729 }
730
731 #[test]
732 fn a_row_is_placed_against_the_scaled_size_of_the_row_between_it_and_the_centre() {
733 let viewport = 192.0;
734 let density = 2.0;
735 let (gap, height) = (4.0, 52.0);
736 let anchor_top = 70.0;
737
738 let anchor = place_row(viewport, anchor_top, height, density).unwrap();
739 assert_eq!(anchor.scale, 1.0, "the anchored row is not scaled");
740 assert_eq!(
741 anchor.reported_height, height,
742 "so it reports its full height"
743 );
744
745 let first_top = anchor_top + anchor.reported_height + gap;
746 assert_eq!(first_top, anchor_top + height + gap);
747 let first = place_row(viewport, first_top, height, density).unwrap();
748 assert!(first.scale < 1.0, "{first:?}");
749 assert!(
750 first.reported_height < height,
751 "and it reports less than its full height: {first:?}"
752 );
753
754 let second_top = first_top + first.reported_height + gap;
755 let stacked_top = first_top + height + gap;
756 assert!(
757 second_top < stacked_top,
758 "cursor {second_top} vs full stack {stacked_top}"
759 );
760 let by_cursor = place_row(viewport, second_top, height, density).unwrap();
761 let by_stack = place_row(viewport, stacked_top, height, density).unwrap();
762 assert_ne!(by_cursor.top, by_stack.top);
763 }
764
765 #[test]
766 fn the_reported_height_is_the_rounded_one_and_the_drawn_height_is_not() {
767 let placed = place_row(VIEWPORT, 3.0, 25.5, 2.0).unwrap();
768 assert!(
769 placed.scale < 1.0,
770 "needs to be in the scaled band: {placed:?}"
771 );
772 assert_eq!(
773 placed.reported_height * 2.0,
774 (placed.reported_height * 2.0).round(),
775 "the reported height is a whole pixel: {placed:?}"
776 );
777 assert_ne!(placed.reported_height, placed.height);
778 }
779
780 #[test]
781 fn a_stack_puts_full_heights_a_gap_apart() {
782 let mut slots = Vec::new();
783 stack_into([10.0, 20.0, 30.0], 4.0, &mut slots);
784 assert_eq!(
785 slots,
786 vec![
787 Slot {
788 top: 0.0,
789 height: 10.0
790 },
791 Slot {
792 top: 14.0,
793 height: 20.0
794 },
795 Slot {
796 top: 38.0,
797 height: 30.0
798 },
799 ]
800 );
801 assert_eq!(slots[1].centre(), 24.0);
802 assert_eq!(slots[2].bottom(), 68.0);
803 }
804
805 #[test]
806 fn the_centre_anchor_puts_the_anchored_items_centre_on_the_centre_line() {
807 let mut slots = Vec::new();
808 stack_into([40.0, 60.0, 40.0], 4.0, &mut slots);
809 let offset = centre_offset(&slots, VIEWPORT, CentreAnchor::default(), 2.0);
810 shift(&mut slots, offset);
811 assert!(
812 (slots[1].centre() - VIEWPORT * 0.5).abs() < 1e-4,
813 "{slots:?}"
814 );
815 }
816
817 #[test]
818 fn a_scroll_offset_moves_the_content_up() {
819 let mut slots = Vec::new();
820 stack_into([40.0, 60.0, 40.0], 4.0, &mut slots);
821 let still = centre_offset(&slots, VIEWPORT, CentreAnchor::default(), 0.0);
822 let scrolled = centre_offset(
823 &slots,
824 VIEWPORT,
825 CentreAnchor {
826 index: 1,
827 offset: 10.0,
828 },
829 0.0,
830 );
831 assert!((still - scrolled - 10.0).abs() < 1e-4, "{still} {scrolled}");
832 }
833
834 #[test]
835 fn a_fractional_scroll_travels_between_item_centres_not_item_tops() {
836 let mut slots = Vec::new();
837 stack_into([20.0, 100.0], 0.0, &mut slots);
838 let start = centre_offset_at(&slots, VIEWPORT, 0.0, 0.0);
839 let end = centre_offset_at(&slots, VIEWPORT, 1.0, 0.0);
840 let middle = centre_offset_at(&slots, VIEWPORT, 0.5, 0.0);
841 assert!((middle - (start + end) * 0.5).abs() < 1e-4);
842 assert_eq!(
843 start,
844 centre_offset(
845 &slots,
846 VIEWPORT,
847 CentreAnchor {
848 index: 0,
849 offset: 0.0
850 },
851 0.0
852 )
853 );
854 }
855
856 #[test]
857 fn a_scroll_past_either_end_clamps_instead_of_running_off() {
858 let mut slots = Vec::new();
859 stack_into([20.0, 20.0], 4.0, &mut slots);
860 assert_eq!(
861 centre_offset_at(&slots, VIEWPORT, -5.0, 0.0),
862 centre_offset_at(&slots, VIEWPORT, 0.0, 0.0)
863 );
864 assert_eq!(
865 centre_offset_at(&slots, VIEWPORT, 9.0, 0.0),
866 centre_offset_at(&slots, VIEWPORT, 1.0, 0.0)
867 );
868 assert_eq!(centre_offset_at(&[], VIEWPORT, 0.0, 2.0), 0.0);
869 assert_eq!(
870 centre_offset(&[], VIEWPORT, CentreAnchor::default(), 2.0),
871 0.0
872 );
873 }
874
875 #[test]
876 fn rounding_a_length_to_a_pixel_sends_an_exact_half_up_the_way_kotlin_does() {
877 assert_eq!(round_to_px(0.25, 2.0), 0.5);
878 assert_eq!(round_to_px(-0.25, 2.0), 0.0);
879 assert_eq!((-0.5f32).round(), -1.0);
880 assert_eq!(round_to_px(0.3, 0.0), 0.3);
881 assert!(round_to_px(f32::NAN, 2.0).is_nan());
882 }
883
884 #[test]
885 fn the_shift_and_the_two_spacers_are_the_same_arithmetic_seen_from_two_sides() {
886 let viewport_px = 454.0;
887 let mut slots = Vec::new();
888 stack_into([96.0, 104.0, 104.0], 8.0, &mut slots);
889 let anchor = CentreAnchor::default();
890 let (leading, _) = auto_centring_spacers(&slots, viewport_px, anchor);
891 let offset = centre_offset(&slots, viewport_px, anchor, 1.0);
892 assert!((leading - offset).abs() < 1e-4, "{leading} vs {offset}");
893 }
894
895 #[test]
896 fn the_leading_spacer_never_pushes_the_anchor_below_the_centre_line() {
897 let mut slots = Vec::new();
898 stack_into([600.0], 8.0, &mut slots);
899 let anchor = CentreAnchor::default();
900 let (leading, trailing) = auto_centring_spacers(&slots, 454.0, anchor);
901 assert_eq!(leading, 0.0, "a tall first item needs no leading spacer");
902 assert!(trailing >= 0.0, "{trailing}");
903 }
904
905 #[test]
906 fn the_content_padding_is_travel_at_both_ends_and_not_blank_beyond_them() {
907 let mut slots = Vec::new();
908 stack_into([96.0, 104.0, 104.0, 104.0], 8.0, &mut slots);
909 let anchor = slots[1].centre();
910 let last = slots[3].centre();
911 let (start, end) = anchor_travel(anchor, last, 68.0, 68.0);
912 assert_eq!(start, anchor - 68.0);
913 assert_eq!(end, last + 68.0);
914 assert_eq!((end - start) - (last - anchor), 136.0);
915 }
916
917 #[test]
918 fn a_list_with_nowhere_to_go_does_not_travel_backwards() {
919 let mut slots = Vec::new();
920 stack_into([104.0], 8.0, &mut slots);
921 let centre = slots[0].centre();
922 let (start, end) = anchor_travel(centre, centre, 68.0, 0.0);
923 assert!(start <= end, "{start} {end}");
924 assert_eq!(end, centre);
925 }
926
927 #[test]
928 fn an_odd_viewport_gives_its_spare_pixel_to_the_trailing_spacer() {
929 let mut slots = Vec::new();
930 stack_into([100.0, 100.0], 8.0, &mut slots);
931 let anchor = CentreAnchor::default();
932 let (_, odd) = auto_centring_spacers(&slots, 455.0, anchor);
933 let (_, even) = auto_centring_spacers(&slots, 454.0, anchor);
934 assert_eq!(odd - even, 1.0, "odd {odd} even {even}");
935 }
936}