1pub const EDGE_SCALE: f32 = 0.7;
23pub const EDGE_ALPHA: f32 = 0.5;
24pub const MIN_ELEMENT_HEIGHT: f32 = 0.2;
28pub const MAX_ELEMENT_HEIGHT: f32 = 0.6;
29pub const MIN_TRANSITION_AREA: f32 = 0.35;
30pub const MAX_TRANSITION_AREA: f32 = 0.55;
31
32#[derive(Clone, Copy, Debug, PartialEq)]
39pub struct ScalingParams {
40 pub edge_scale: f32,
41 pub edge_alpha: f32,
42 pub min_element_height: f32,
43 pub max_element_height: f32,
44 pub min_transition_area: f32,
45 pub max_transition_area: f32,
46}
47
48impl Default for ScalingParams {
49 fn default() -> Self {
50 Self::WEAR
51 }
52}
53
54impl ScalingParams {
55 pub const WEAR: Self = Self {
57 edge_scale: EDGE_SCALE,
58 edge_alpha: EDGE_ALPHA,
59 min_element_height: MIN_ELEMENT_HEIGHT,
60 max_element_height: MAX_ELEMENT_HEIGHT,
61 min_transition_area: MIN_TRANSITION_AREA,
62 max_transition_area: MAX_TRANSITION_AREA,
63 };
64
65 pub const fn reduced_motion(self) -> Self {
69 Self {
70 edge_scale: 1.0,
71 edge_alpha: 1.0,
72 min_element_height: self.min_element_height,
73 max_element_height: self.max_element_height,
74 min_transition_area: self.min_transition_area,
75 max_transition_area: self.max_transition_area,
76 }
77 }
78}
79
80#[derive(Clone, Copy, Debug, PartialEq)]
82pub struct ScaleAlpha {
83 pub scale: f32,
84 pub alpha: f32,
85}
86
87impl ScaleAlpha {
88 pub const UNCHANGED: Self = Self {
90 scale: 1.0,
91 alpha: 1.0,
92 };
93}
94
95pub fn scale_and_alpha(viewport: f32, top: f32, bottom: f32) -> Option<ScaleAlpha> {
103 scale_and_alpha_with(ScalingParams::WEAR, viewport, top, bottom)
104}
105
106pub fn scale_and_alpha_with(
108 params: ScalingParams,
109 viewport: f32,
110 top: f32,
111 bottom: f32,
112) -> Option<ScaleAlpha> {
113 if !viewport.is_finite() || !top.is_finite() || !bottom.is_finite() || bottom < top {
114 return None;
115 }
116 if viewport <= 0.0 {
117 return Some(ScaleAlpha::UNCHANGED);
118 }
119 let edge = (viewport - top).min(bottom) / viewport;
121 let size_ratio = inverse_lerp(
122 params.min_element_height,
123 params.max_element_height,
124 (bottom - top) / viewport,
125 );
126 let line = params.min_transition_area
127 + (params.max_transition_area - params.min_transition_area) * size_ratio;
128 if edge >= line || line <= 0.0 {
129 return Some(ScaleAlpha::UNCHANGED);
130 }
131 let progress = ease(1.0 - edge / line);
135 Some(ScaleAlpha {
136 scale: 1.0 + (params.edge_scale - 1.0) * progress,
137 alpha: 1.0 + (params.edge_alpha - 1.0) * progress,
138 })
139}
140
141#[derive(Clone, Copy, Debug, PartialEq)]
143pub struct PlacedRow {
144 pub top: f32,
146 pub height: f32,
148 pub scale: f32,
149 pub alpha: f32,
150}
151
152pub fn place_row(viewport: f32, top: f32, height: f32, density: f32) -> Option<PlacedRow> {
168 place_row_with(ScalingParams::WEAR, viewport, top, height, density)
169}
170
171pub fn place_row_with(
173 params: ScalingParams,
174 viewport: f32,
175 top: f32,
176 height: f32,
177 density: f32,
178) -> Option<PlacedRow> {
179 if !height.is_finite() || height < 0.0 || !density.is_finite() {
180 return None;
181 }
182 if density <= 0.0 {
183 let transform = scale_and_alpha_with(params, viewport, top, top + height)?;
184 return Some(PlacedRow {
185 top,
186 height: height * transform.scale,
187 scale: transform.scale,
188 alpha: transform.alpha,
189 });
190 }
191 let viewport_px = (viewport * density).round();
192 let top_px = (top * density).round();
193 let height_px = (height * density).round();
194 let transform = scale_and_alpha(viewport_px, top_px, top_px + height_px)?;
195 let scaled_px = (height_px * transform.scale).round();
196 let above = top_px + top_px + height_px < viewport_px;
199 let pinned = if above {
200 top_px + height_px - scaled_px
201 } else {
202 top_px
203 };
204 Some(PlacedRow {
205 top: (pinned + odd_pixel(height_px) - odd_pixel(scaled_px)) / density,
206 height: height_px * transform.scale / density,
207 scale: transform.scale,
208 alpha: transform.alpha,
209 })
210}
211
212#[derive(Clone, Copy, Debug, PartialEq)]
219pub struct Slot {
220 pub top: f32,
221 pub height: f32,
222}
223
224impl Slot {
225 pub fn centre(self) -> f32 {
226 self.top + self.height * 0.5
227 }
228
229 pub fn bottom(self) -> f32 {
230 self.top + self.height
231 }
232}
233
234pub fn stack_into(heights: impl IntoIterator<Item = f32>, gap: f32, out: &mut Vec<Slot>) {
240 out.clear();
241 let mut cursor = 0.0;
242 for height in heights {
243 out.push(Slot {
244 top: cursor,
245 height,
246 });
247 cursor += height + gap;
248 }
249}
250
251#[derive(Clone, Copy, Debug, PartialEq)]
259pub struct CentreAnchor {
260 pub index: usize,
261 pub offset: f32,
262}
263
264impl Default for CentreAnchor {
265 fn default() -> Self {
267 Self {
268 index: 1,
269 offset: 0.0,
270 }
271 }
272}
273
274pub fn round_to_px(value: f32, density: f32) -> f32 {
282 if density <= 0.0 || !density.is_finite() || !value.is_finite() {
283 return value;
284 }
285 (value * density + 0.5).floor() / density
286}
287
288pub fn centre_offset(slots: &[Slot], viewport: f32, anchor: CentreAnchor, density: f32) -> f32 {
303 let Some(slot) = slots.get(anchor.index).or_else(|| slots.last()) else {
304 return 0.0;
305 };
306 round_to_px(viewport * 0.5 - slot.centre() - anchor.offset, density)
307}
308
309pub fn centre_offset_at(slots: &[Slot], viewport: f32, scroll: f32, density: f32) -> f32 {
317 if slots.is_empty() {
318 return 0.0;
319 }
320 let scroll = if scroll.is_finite() { scroll } else { 0.0 };
321 let whole = (scroll.floor().max(0.0) as usize).min(slots.len() - 1);
322 let fraction = (scroll - whole as f32).clamp(0.0, 1.0);
323 let mut anchor = slots[whole].centre();
324 if let Some(next) = slots.get(whole + 1) {
325 anchor += (next.centre() - anchor) * fraction;
326 }
327 round_to_px(viewport * 0.5 - anchor, density)
328}
329
330pub fn shift(slots: &mut [Slot], offset: f32) {
332 for slot in slots.iter_mut() {
333 slot.top += offset;
334 }
335}
336
337pub fn auto_centring_spacers(slots: &[Slot], viewport_px: f32, anchor: CentreAnchor) -> (f32, f32) {
353 let centre_line = (viewport_px * 0.5).floor();
354 let leading = slots
355 .get(anchor.index)
356 .or_else(|| slots.last())
357 .map(|slot| (centre_line - anchor.offset - slot.centre()).max(0.0))
358 .unwrap_or(0.0);
359 let trailing = slots
361 .last()
362 .map(|slot| (viewport_px - centre_line - slot.height * 0.5).max(0.0))
363 .unwrap_or(0.0);
364 (leading, trailing)
365}
366
367fn odd_pixel(pixels: f32) -> f32 {
370 let half = pixels * 0.5;
371 half - half.floor()
372}
373
374fn inverse_lerp(start: f32, stop: f32, value: f32) -> f32 {
375 ((value - start) / (stop - start)).clamp(0.0, 1.0)
376}
377
378fn ease(x: f32) -> f32 {
384 let x = x.clamp(0.0, 1.0);
385 let mut low = 0.0f32;
386 let mut high = 1.0f32;
387 let mut t = x;
388 for _ in 0..12 {
389 let value = bezier(t, 0.3, 0.7);
390 if value < x {
391 low = t;
392 } else {
393 high = t;
394 }
395 t = (low + high) * 0.5;
396 }
397 bezier(t, 0.0, 1.0)
398}
399
400fn bezier(t: f32, first: f32, second: f32) -> f32 {
401 let inverse = 1.0 - t;
402 3.0 * inverse * inverse * t * first + 3.0 * inverse * t * t * second + t * t * t
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408
409 const VIEWPORT: f32 = 227.0;
410
411 #[test]
412 fn a_row_in_the_middle_is_left_alone() {
413 let middle = scale_and_alpha(VIEWPORT, VIEWPORT * 0.45, VIEWPORT * 0.55).unwrap();
414 assert_eq!(middle, ScaleAlpha::UNCHANGED);
415 }
416
417 #[test]
418 fn a_row_at_the_edge_is_shrunk_and_faded_together() {
419 let edge = scale_and_alpha(VIEWPORT, 0.0, 20.0).unwrap();
420 assert!(edge.scale < 1.0 && edge.scale >= EDGE_SCALE, "{edge:?}");
421 assert!(edge.alpha < 1.0 && edge.alpha >= EDGE_ALPHA, "{edge:?}");
422 let top = scale_and_alpha(VIEWPORT, 0.0, 0.0).unwrap();
425 assert!((top.scale - EDGE_SCALE).abs() < 1e-3, "{top:?}");
426 assert!((top.alpha - EDGE_ALPHA).abs() < 1e-3, "{top:?}");
427 }
428
429 #[test]
430 fn the_two_edges_treat_a_row_the_same() {
431 let height = 40.0;
432 let near_top = scale_and_alpha(VIEWPORT, 8.0, 8.0 + height).unwrap();
433 let near_bottom =
434 scale_and_alpha(VIEWPORT, VIEWPORT - 8.0 - height, VIEWPORT - 8.0).unwrap();
435 assert!((near_top.scale - near_bottom.scale).abs() < 1e-5);
436 assert!((near_top.alpha - near_bottom.alpha).abs() < 1e-5);
437 }
438
439 #[test]
440 fn a_taller_row_starts_shrinking_further_from_the_edge() {
441 let top = VIEWPORT - 10.0;
447 let short = scale_and_alpha(VIEWPORT, top, top + VIEWPORT * 0.2).unwrap();
448 let tall = scale_and_alpha(VIEWPORT, top, top + VIEWPORT * 0.62).unwrap();
449 assert!(tall.scale < short.scale, "short {short:?} tall {tall:?}");
450 }
451
452 #[test]
453 fn a_row_is_placed_from_the_full_heights_above_it_not_the_scaled_ones() {
454 let first = place_row(VIEWPORT, 0.0, 50.0, 2.0).unwrap();
457 let second = place_row(VIEWPORT, 50.0, 50.0, 2.0).unwrap();
458 assert!(first.scale < 1.0, "the first row is at the edge: {first:?}");
459 assert!(second.top >= 49.0, "{second:?}");
461 }
462
463 #[test]
464 fn a_row_above_the_centre_line_keeps_its_bottom_edge() {
465 let above = place_row(VIEWPORT, 4.0, 50.0, 2.0).unwrap();
468 assert!(above.scale < 1.0, "{above:?}");
469 assert!(
470 above.top > 4.0,
471 "shrinking should pull the top down: {above:?}"
472 );
473
474 let below = place_row(VIEWPORT, VIEWPORT - 54.0, 50.0, 2.0).unwrap();
475 assert!(below.scale < 1.0, "{below:?}");
476 assert!(
477 (below.top - (VIEWPORT - 54.0)).abs() < 0.6,
478 "the top is pinned below the line: {below:?}"
479 );
480 }
481
482 #[test]
483 fn an_odd_pixel_height_carries_the_half_pixel_composes_integer_halving_leaves() {
484 assert_eq!(odd_pixel(50.0), 0.0);
487 assert_eq!(odd_pixel(51.0), 0.5);
488 let odd = place_row(VIEWPORT, 3.0, 25.5, 2.0).unwrap();
489 assert!(odd.scale < 1.0, "needs to be in the scaled band: {odd:?}");
490 }
491
492 #[test]
493 fn a_density_of_zero_falls_back_to_continuous_placement_instead_of_dividing_by_it() {
494 let placed = place_row(VIEWPORT, 10.0, 50.0, 0.0).unwrap();
495 assert!(
496 placed.top.is_finite() && placed.height.is_finite(),
497 "{placed:?}"
498 );
499 assert_eq!(placed.top, 10.0);
500 let negative = place_row(VIEWPORT, 10.0, 50.0, -2.0).unwrap();
501 assert_eq!(negative, placed, "a nonsense density is not a crash");
502 }
503
504 #[test]
505 fn an_empty_viewport_leaves_everything_alone_rather_than_dividing_by_it() {
506 assert_eq!(scale_and_alpha(0.0, 0.0, 10.0), Some(ScaleAlpha::UNCHANGED));
507 assert_eq!(
508 scale_and_alpha(-5.0, 0.0, 10.0),
509 Some(ScaleAlpha::UNCHANGED)
510 );
511 }
512
513 #[test]
514 fn invalid_geometry_is_rejected_instead_of_producing_nan() {
515 assert_eq!(scale_and_alpha(f32::NAN, 0.0, 10.0), None);
516 assert_eq!(scale_and_alpha(VIEWPORT, 10.0, 9.0), None);
517 assert_eq!(place_row(VIEWPORT, 0.0, -1.0, 2.0), None);
518 assert_eq!(place_row(VIEWPORT, 0.0, 10.0, f32::INFINITY), None);
519 }
520
521 #[test]
522 fn the_easing_is_monotonic_and_spans_the_whole_range() {
523 assert!((ease(0.0) - 0.0).abs() < 1e-3, "{}", ease(0.0));
524 assert!((ease(1.0) - 1.0).abs() < 1e-3, "{}", ease(1.0));
525 let mut previous = -1.0;
526 for step in 0..=20 {
527 let value = ease(step as f32 / 20.0);
528 assert!(value >= previous - 1e-4, "not monotonic at {step}");
529 previous = value;
530 }
531 }
532
533 #[test]
534 fn the_easing_matches_the_current_wear_compose_curve() {
535 assert!((ease(0.25) - 0.166_779).abs() < 1e-3, "{}", ease(0.25));
536 }
537
538 #[test]
539 fn the_default_scaling_params_are_the_constants_the_module_documents() {
540 let params = ScalingParams::default();
541 assert_eq!(params.edge_scale, EDGE_SCALE);
542 assert_eq!(params.edge_alpha, EDGE_ALPHA);
543 assert_eq!(params.min_element_height, MIN_ELEMENT_HEIGHT);
544 assert_eq!(params.max_element_height, MAX_ELEMENT_HEIGHT);
545 assert_eq!(params.min_transition_area, MIN_TRANSITION_AREA);
546 assert_eq!(params.max_transition_area, MAX_TRANSITION_AREA);
547 assert_eq!(
549 scale_and_alpha_with(params, VIEWPORT, 0.0, 20.0),
550 scale_and_alpha(VIEWPORT, 0.0, 20.0)
551 );
552 assert_eq!(
553 place_row_with(params, VIEWPORT, 4.0, 50.0, 2.0),
554 place_row(VIEWPORT, 4.0, 50.0, 2.0)
555 );
556 }
557
558 #[test]
559 fn reduced_motion_turns_the_ramp_off_rather_than_damping_it() {
560 let params = ScalingParams::default().reduced_motion();
561 let edge = scale_and_alpha_with(params, VIEWPORT, 0.0, 0.0).unwrap();
562 assert_eq!(edge, ScaleAlpha::UNCHANGED);
563 }
564
565 #[test]
566 fn a_stack_puts_full_heights_a_gap_apart() {
567 let mut slots = Vec::new();
568 stack_into([10.0, 20.0, 30.0], 4.0, &mut slots);
569 assert_eq!(
570 slots,
571 vec![
572 Slot {
573 top: 0.0,
574 height: 10.0
575 },
576 Slot {
577 top: 14.0,
578 height: 20.0
579 },
580 Slot {
581 top: 38.0,
582 height: 30.0
583 },
584 ]
585 );
586 assert_eq!(slots[1].centre(), 24.0);
587 assert_eq!(slots[2].bottom(), 68.0);
588 }
589
590 #[test]
591 fn the_centre_anchor_puts_the_anchored_items_centre_on_the_centre_line() {
592 let mut slots = Vec::new();
593 stack_into([40.0, 60.0, 40.0], 4.0, &mut slots);
594 let offset = centre_offset(&slots, VIEWPORT, CentreAnchor::default(), 2.0);
597 shift(&mut slots, offset);
598 assert!(
599 (slots[1].centre() - VIEWPORT * 0.5).abs() < 1e-4,
600 "{slots:?}"
601 );
602 }
603
604 #[test]
605 fn a_scroll_offset_moves_the_content_up() {
606 let mut slots = Vec::new();
607 stack_into([40.0, 60.0, 40.0], 4.0, &mut slots);
608 let still = centre_offset(&slots, VIEWPORT, CentreAnchor::default(), 0.0);
609 let scrolled = centre_offset(
610 &slots,
611 VIEWPORT,
612 CentreAnchor {
613 index: 1,
614 offset: 10.0,
615 },
616 0.0,
617 );
618 assert!((still - scrolled - 10.0).abs() < 1e-4, "{still} {scrolled}");
619 }
620
621 #[test]
622 fn a_fractional_scroll_travels_between_item_centres_not_item_tops() {
623 let mut slots = Vec::new();
624 stack_into([20.0, 100.0], 0.0, &mut slots);
627 let start = centre_offset_at(&slots, VIEWPORT, 0.0, 0.0);
628 let end = centre_offset_at(&slots, VIEWPORT, 1.0, 0.0);
629 let middle = centre_offset_at(&slots, VIEWPORT, 0.5, 0.0);
630 assert!((middle - (start + end) * 0.5).abs() < 1e-4);
631 assert_eq!(
633 start,
634 centre_offset(
635 &slots,
636 VIEWPORT,
637 CentreAnchor {
638 index: 0,
639 offset: 0.0
640 },
641 0.0
642 )
643 );
644 }
645
646 #[test]
647 fn a_scroll_past_either_end_clamps_instead_of_running_off() {
648 let mut slots = Vec::new();
649 stack_into([20.0, 20.0], 4.0, &mut slots);
650 assert_eq!(
651 centre_offset_at(&slots, VIEWPORT, -5.0, 0.0),
652 centre_offset_at(&slots, VIEWPORT, 0.0, 0.0)
653 );
654 assert_eq!(
655 centre_offset_at(&slots, VIEWPORT, 9.0, 0.0),
656 centre_offset_at(&slots, VIEWPORT, 1.0, 0.0)
657 );
658 assert_eq!(centre_offset_at(&[], VIEWPORT, 0.0, 2.0), 0.0);
659 assert_eq!(
660 centre_offset(&[], VIEWPORT, CentreAnchor::default(), 2.0),
661 0.0
662 );
663 }
664
665 #[test]
666 fn rounding_a_length_to_a_pixel_sends_an_exact_half_up_the_way_kotlin_does() {
667 assert_eq!(round_to_px(0.25, 2.0), 0.5);
669 assert_eq!(round_to_px(-0.25, 2.0), 0.0);
670 assert_eq!((-0.5f32).round(), -1.0);
673 assert_eq!(round_to_px(0.3, 0.0), 0.3);
674 assert!(round_to_px(f32::NAN, 2.0).is_nan());
675 }
676
677 #[test]
678 fn the_shift_and_the_two_spacers_are_the_same_arithmetic_seen_from_two_sides() {
679 let viewport_px = 454.0;
682 let mut slots = Vec::new();
683 stack_into([96.0, 104.0, 104.0], 8.0, &mut slots);
684 let anchor = CentreAnchor::default();
685 let (leading, _) = auto_centring_spacers(&slots, viewport_px, anchor);
686 let offset = centre_offset(&slots, viewport_px, anchor, 1.0);
687 assert!((leading - offset).abs() < 1e-4, "{leading} vs {offset}");
688 }
689
690 #[test]
691 fn the_leading_spacer_never_pushes_the_anchor_below_the_centre_line() {
692 let mut slots = Vec::new();
695 stack_into([600.0], 8.0, &mut slots);
696 let anchor = CentreAnchor::default();
697 let (leading, trailing) = auto_centring_spacers(&slots, 454.0, anchor);
698 assert_eq!(leading, 0.0, "a tall first item needs no leading spacer");
699 assert!(trailing >= 0.0, "{trailing}");
700 }
701
702 #[test]
703 fn an_odd_viewport_gives_its_spare_pixel_to_the_trailing_spacer() {
704 let mut slots = Vec::new();
705 stack_into([100.0, 100.0], 8.0, &mut slots);
706 let anchor = CentreAnchor::default();
707 let (_, odd) = auto_centring_spacers(&slots, 455.0, anchor);
708 let (_, even) = auto_centring_spacers(&slots, 454.0, anchor);
709 assert_eq!(odd - even, 1.0, "odd {odd} even {even}");
710 }
711}