Skip to main content

cranpose_ui/
round_scaling_list.rs

1//! Placing list rows the way a round watch scales them.
2//!
3//! A scaling list shrinks and fades its rows towards the top and bottom of the
4//! display so the content follows the bezel. The surprising part, and the part
5//! that is wrong in every from-scratch implementation, is that **the list does
6//! not re-measure a scaled row**: the column underneath stacks rows at their
7//! FULL height, and each is then drawn through a graphics layer whose
8//! `transformOrigin` sits on the edge facing the centre line. A row's position
9//! therefore depends only on the rows above it, never on how much any of them
10//! shrank. Scale first and stack the scaled heights and the list drifts further
11//! out of place with every row.
12//!
13//! Derived from `androidx.wear.compose.foundation.lazy`
14//! (`ScalingLazyColumnItemWrapper`, `calculateScaleAndAlpha`, and
15//! `convertToCenterOffset`), then checked against where Compose puts rows on
16//! 454x454 and 384x384 displays.
17//!
18//! This is pure geometry: it answers where a row goes and takes no view of how
19//! it is drawn.
20
21/// How far a row at the very edge is shrunk and faded.
22pub const EDGE_SCALE: f32 = 0.7;
23pub const EDGE_ALPHA: f32 = 0.5;
24/// The row-height range, as a share of the viewport, over which the transition
25/// band grows from [`MIN_TRANSITION_AREA`] to [`MAX_TRANSITION_AREA`]. A taller
26/// row starts shrinking further from the edge than a short one.
27pub 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/// AOSP's `ScalingParams`, as a value rather than six constants.
33///
34/// `ScalingLazyColumn` takes these as a parameter; the module-level constants
35/// above are the defaults it supplies. Holding them in a struct is what lets a
36/// caller turn scaling off (see [`ScalingParams::reduced_motion`]) without a
37/// second code path.
38#[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    /// `ScalingLazyColumnDefaults.scalingParams()`.
56    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    /// What Wear uses under `LocalReduceMotion`: both edge values forced to
66    /// `1.0`, which disables scaling and fading entirely rather than damping
67    /// them.
68    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/// How much a row is shrunk and faded at a given position.
81#[derive(Clone, Copy, Debug, PartialEq)]
82pub struct ScaleAlpha {
83    pub scale: f32,
84    pub alpha: f32,
85}
86
87impl ScaleAlpha {
88    /// A row sitting fully inside the untransformed middle of the list.
89    pub const UNCHANGED: Self = Self {
90        scale: 1.0,
91        alpha: 1.0,
92    };
93}
94
95/// Wear's `calculateScaleAndAlpha`, for a row spanning `top..bottom` in a
96/// viewport of `viewport`.
97///
98/// All three are in one unit — device pixels if you want to match Compose
99/// exactly, since it does this arithmetic on integers.
100/// Returns `None` when the geometry is non-finite or the row has negative
101/// height.
102pub fn scale_and_alpha(viewport: f32, top: f32, bottom: f32) -> Option<ScaleAlpha> {
103    scale_and_alpha_with(ScalingParams::WEAR, viewport, top, bottom)
104}
105
106/// [`scale_and_alpha`] with the ramp's six knobs supplied.
107pub 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    // Distance to whichever edge this row is nearer, as a share of the viewport.
120    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    // Wear does not clamp this before easing, so an item scrolled past the edge
132    // reads `edge < 0` and comes out below `edge_scale`. `ease` clamps, which
133    // is the behaviour a port wants and the one the spec recommends.
134    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/// Where a row ends up once the list has scaled it.
142#[derive(Clone, Copy, Debug, PartialEq)]
143pub struct PlacedRow {
144    /// Top edge after the transform, in the unit `top` was given in.
145    pub top: f32,
146    /// Height after the transform.
147    pub height: f32,
148    pub scale: f32,
149    pub alpha: f32,
150}
151
152/// Places a row the way a scaling list places one.
153///
154/// `top` is where the row would sit with nothing scaled — the running total of
155/// the FULL heights of the rows above it — and `height` is its full height.
156/// `density` is device pixels per unit; pass `0.0` to skip the pixel rounding
157/// and work in continuous coordinates.
158///
159/// Compose does this on integers, and two details of that survive into the
160/// result. The scaled height is rounded to a whole pixel before the row is
161/// pinned, and `convertToCenterOffset` halves a size with integer division
162/// while the offset it is compared against halves in floating point — so an odd
163/// pixel height carries exactly half a pixel that a float-only implementation
164/// loses.
165///
166/// Returns `None` for non-finite geometry or a negative height.
167pub 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
171/// [`place_row`] with the ramp's six knobs supplied.
172pub 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    // Wear's `isAboveLine`, on the same integers it uses: a row above the
197    // centre line keeps its BOTTOM edge, one below keeps its top.
198    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/// A row's unscaled place in the column: where it would sit and how tall it is
213/// with nothing scaled.
214///
215/// This is the coordinate space the whole module works in. [`place_row`] turns
216/// a slot into the transformed rectangle that is actually drawn; the slot
217/// itself never moves because a row shrank.
218#[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
234/// Stacks row heights into slots, `gap` apart, starting at zero.
235///
236/// The stack is of FULL heights — that is the invariant the whole scaling model
237/// rests on, and stacking scaled heights instead is the mistake this module
238/// exists to prevent.
239pub 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/// Which item the list holds on its centre line, and by how much it is offset.
252///
253/// This is `ScalingLazyListState`'s coordinate pair — `centerItemIndex` plus
254/// `centerItemScrollOffset` — under the default `ScalingLazyListAnchorType.ItemCenter`,
255/// where the anchored point is the item's centre rather than its top edge.
256/// A positive `offset` scrolls the content up, the same sign as a scroll
257/// position.
258#[derive(Clone, Copy, Debug, PartialEq)]
259pub struct CentreAnchor {
260    pub index: usize,
261    pub offset: f32,
262}
263
264impl Default for CentreAnchor {
265    /// `rememberScalingLazyListState()`'s own default: the second item, centred.
266    fn default() -> Self {
267        Self {
268            index: 1,
269            offset: 0.0,
270        }
271    }
272}
273
274/// A length moved onto the whole device pixel Compose would give it.
275///
276/// Compose's layout is integral — `Dp.roundToPx()` runs before anything is
277/// measured and children are placed at an `IntOffset` — and Kotlin's
278/// `roundToInt` sends an exact half **up**, not away from zero. Rust's
279/// `f32::round` disagrees on exactly the negative halves, which is the case a
280/// scroll offset reaches.
281pub 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
288/// How far the whole column must move so the anchored item sits on the centre
289/// line — Wear's `autoCentering`, as one shift rather than two spacers.
290///
291/// Wear expresses this by injecting a `Spacer` before and after the content
292/// (see [`auto_centring_spacers`]), which is the same arithmetic seen from the
293/// other side: with the leading spacer un-clamped, the content offset it
294/// produces is exactly this shift. Returning the shift lets a caller place rows
295/// directly instead of measuring two phantom items.
296///
297/// The result is rounded to a whole device pixel, because the `LazyColumn`
298/// underneath holds its scroll position as a whole number of pixels: a float
299/// delta is rounded before it is applied and the remainder carried, so every
300/// item top stays integral. Rounding once here does that for the whole column.
301/// Pass `density <= 0.0` to work in continuous coordinates.
302pub 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
309/// [`centre_offset`] for a caller that holds its scroll position as a
310/// fractional item index rather than an index and a pixel offset.
311///
312/// `scroll` of `2.5` centres the point halfway between the third and fourth
313/// items' centres. This is the shape an app that scrolls by whole rows wants,
314/// and it interpolates between item *centres* rather than tops so a tall row
315/// next to a short one does not accelerate through the middle.
316pub 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
330/// Moves every slot by `offset`.
331pub fn shift(slots: &mut [Slot], offset: f32) {
332    for slot in slots.iter_mut() {
333        slot.top += offset;
334    }
335}
336
337/// The two spacer heights Wear's `autoCentering` injects around the content.
338///
339/// Wear does not shift the column; it inserts a `Spacer` item before all
340/// content and another after it, which is why `totalItemsCount` is two less
341/// than the `LazyColumn`'s and every public index is one higher. Both are
342/// reproduced here because the numbers differ from the plain shift in two
343/// places that show on screen:
344///
345/// - the leading spacer is clamped at zero, so the anchored item cannot be
346///   pushed *below* the centre line by a short list;
347/// - the centre line is `floor(viewport / 2)` on an integer pixel grid, so an
348///   odd viewport gives its spare pixel to the trailing spacer.
349///
350/// `viewport` and the slot geometry are in device pixels here, not points —
351/// that is the space Wear does this arithmetic in.
352pub 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    // `unadjustedSizeBelowOffsetPoint` under `ItemCenter` is half the item.
360    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
367/// Half a pixel when a pixel height is odd, nothing when it is even — what
368/// Compose's integer halving leaves behind beside its floating-point one.
369fn 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
378/// Wear's transition easing, `CubicBezierEasing(0.3, 0.0, 0.7, 1.0)`.
379///
380/// A Compose easing curve is parametric, so the answer is the curve's y at the
381/// parameter whose x is `fraction`. Twelve bisections put the result inside
382/// 1/4096, which is far below a pixel at any watch size.
383fn 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        // Both run to their limits together, so a row never fades without
423        // shrinking or the reverse.
424        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        // The transition band grows with row height. Isolating that needs two
442        // rows at the SAME distance from an edge: anchor both near the bottom,
443        // where `edge` is measured from the top edge and so does not move when
444        // the height does. The taller row has the wider band, so the same
445        // distance is a larger fraction of it and it shrinks more.
446        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        // Two rows of the same full height at the same unscaled offsets must
455        // land where those offsets say, however much the first one shrank.
456        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        // The second row's position is not pushed up by the first row shrinking.
460        assert!(second.top >= 49.0, "{second:?}");
461    }
462
463    #[test]
464    fn a_row_above_the_centre_line_keeps_its_bottom_edge() {
465        // Above the line the transform origin is the bottom, so shrinking pulls
466        // the top down; below the line the top is pinned and the bottom rises.
467        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        // 25 units at density 2 is a 50px row — even. 25.5 is 51px — odd, and
485        // that half pixel is exactly what a float-only implementation drops.
486        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        // And the parameterised entry points agree with the fixed ones.
548        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        // Item 1 spans 44..104, centre 74. The viewport centre is 113.5, which
595        // at density 2 is a whole pixel, so the shift is exact.
596        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        // A short row beside a tall one: interpolating tops would move the
625        // anchor by the first row's height, centres by the mean of the two.
626        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        // And the endpoints agree with the index-and-offset form.
632        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        // 0.25 at density 2 is exactly half a pixel.
668        assert_eq!(round_to_px(0.25, 2.0), 0.5);
669        assert_eq!(round_to_px(-0.25, 2.0), 0.0);
670        // Rust's own rounding sends the negative half the other way, which is
671        // the disagreement this helper exists to settle.
672        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        // In pixels, on an even viewport, with the anchored item far enough
680        // down that the leading spacer is not clamped.
681        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        // One short item: the plain shift would be positive and large, and the
693        // clamp is what stops the list from starting scrolled.
694        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}