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 `LazyColumn` underneath stacks rows at
7//! their FULL height, measures and scrolls in that space, and each row is then
8//! moved and shrunk by a graphics layer over the top of it.
9//!
10//! # What that layer is told, and what it is not
11//!
12//! It is tempting to conclude that a row's drawn position therefore depends
13//! only on the rows above it and never on how much any of them shrank. That is
14//! true of the `LazyColumn`, and false of what you see.
15//! `ScalingLazyColumnItemWrapper` sets
16//!
17//! ```text
18//! translationY = startOffset(item, anchorType) - unadjustedStartOffset(item, anchorType)
19//! ```
20//!
21//! and both halves come out of `ScalingLazyListState.layoutInfo`, which builds
22//! its window by walking **outward from the centre item** with a cursor that
23//! advances by each row's `ScalingLazyListItemInfo.size` — the scaled size,
24//! `roundToInt(size * scale)` — plus the gap. Downward the next row starts at
25//! that cursor; upward the cursor is the next row's bottom. So the drawn boxes
26//! are stacked edge to edge **at their scaled sizes**, and the Nth row out does
27//! depend on how much the N-1 rows between it and the centre shrank.
28//!
29//! The two accounts agree exactly for the centre row (scale 1, so its scaled
30//! size is its full one) and for its immediate neighbours, and separate from
31//! the second row out. How far they separate is the whole of what the rows in
32//! between shrank, so it depends on the list: on six 52pt rows down a 454pt
33//! watch the third row out is 8.5pt higher under this rule and comes fully on
34//! screen where the full-height stack ran it off the bottom, while on the real
35//! Settings list it is a device pixel of the bottom row's sliver at 192dp and
36//! nothing at all at 227dp. The **shape** of the error is the part worth
37//! keeping in mind: under the full-height stack the drawn boxes drift apart as
38//! they shrink, and under this one they stay exactly one gap apart however
39//! small they get.
40//!
41//! [`place_row`] therefore takes the **cursor**, not a slot in the unscaled
42//! stack; [`PlacedRow::reported_height`] is what advances it; and
43//! [`place_rows`] is the walk, because a per-row call cannot state a rule about
44//! the row after it.
45//!
46//! The ramp itself is still stated on the row's FULL height at that cursor —
47//! `calculateItemInfo` passes `itemStart .. itemStart + item.size` — so a row
48//! is scaled by where its unshrunk box would fall and then pinned by whichever
49//! edge faces the centre line.
50//!
51//! Derived from `androidx.wear.compose.foundation.lazy`
52//! (`ScalingLazyListState.layoutInfo`, `ScalingLazyColumnItemWrapper`,
53//! `calculateItemInfo`, `calculateScaleAndAlpha` and `convertToCenterOffset`,
54//! disassembled out of compose-foundation 1.6.2), then checked against where
55//! Compose puts rows on 454x454 and 384x384 displays.
56//!
57//! This is pure geometry: it answers where a row goes and takes no view of how
58//! it is drawn.
59
60/// How far a row at the very edge is shrunk and faded.
61pub const EDGE_SCALE: f32 = 0.7;
62pub const EDGE_ALPHA: f32 = 0.5;
63/// The row-height range, as a share of the viewport, over which the transition
64/// band grows from [`MIN_TRANSITION_AREA`] to [`MAX_TRANSITION_AREA`]. A taller
65/// row starts shrinking further from the edge than a short one.
66pub 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/// AOSP's `ScalingParams`, as a value rather than six constants.
72///
73/// `ScalingLazyColumn` takes these as a parameter; the module-level constants
74/// above are the defaults it supplies. Holding them in a struct is what lets a
75/// caller turn scaling off (see [`ScalingParams::reduced_motion`]) without a
76/// second code path.
77#[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    /// `ScalingLazyColumnDefaults.scalingParams()`.
95    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    /// What Wear uses under `LocalReduceMotion`: both edge values forced to
105    /// `1.0`, which disables scaling and fading entirely rather than damping
106    /// them.
107    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/// How much a row is shrunk and faded at a given position.
120#[derive(Clone, Copy, Debug, PartialEq)]
121pub struct ScaleAlpha {
122    pub scale: f32,
123    pub alpha: f32,
124}
125
126impl ScaleAlpha {
127    /// A row sitting fully inside the untransformed middle of the list.
128    pub const UNCHANGED: Self = Self {
129        scale: 1.0,
130        alpha: 1.0,
131    };
132}
133
134/// Wear's `calculateScaleAndAlpha`, for a row spanning `top..bottom` in a
135/// viewport of `viewport`.
136///
137/// All three are in one unit — device pixels if you want to match Compose
138/// exactly, since it does this arithmetic on integers.
139/// Returns `None` when the geometry is non-finite or the row has negative
140/// height.
141pub fn scale_and_alpha(viewport: f32, top: f32, bottom: f32) -> Option<ScaleAlpha> {
142    scale_and_alpha_with(ScalingParams::WEAR, viewport, top, bottom)
143}
144
145/// [`scale_and_alpha`] with the ramp's six knobs supplied.
146pub 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    // Distance to whichever edge this row is nearer, as a share of the viewport.
159    let edge = (viewport - top).min(bottom) / viewport;
160    let size_ratio = inverse_lerp(
161        params.min_element_height,
162        params.max_element_height,
163        (bottom - top) / viewport,
164    );
165    let line = params.min_transition_area
166        + (params.max_transition_area - params.min_transition_area) * size_ratio;
167    if edge >= line || line <= 0.0 {
168        return Some(ScaleAlpha::UNCHANGED);
169    }
170    // Wear does not clamp this before easing, so an item scrolled past the edge
171    // reads `edge < 0` and comes out below `edge_scale`. `ease` clamps, which
172    // is the behaviour a port wants and the one the spec recommends.
173    let progress = ease(1.0 - edge / line);
174    Some(ScaleAlpha {
175        scale: 1.0 + (params.edge_scale - 1.0) * progress,
176        alpha: 1.0 + (params.edge_alpha - 1.0) * progress,
177    })
178}
179
180/// Where a row ends up once the list has scaled it.
181#[derive(Clone, Copy, Debug, PartialEq)]
182pub struct PlacedRow {
183    /// Top edge after the transform, in the unit `top` was given in.
184    pub top: f32,
185    /// Height after the transform.
186    pub height: f32,
187    /// The height the layout **reports** for this row:
188    /// `ScalingLazyListItemInfo.size`, which is `roundToInt(size * scale)`.
189    ///
190    /// It is not [`Self::height`]. The graphics layer scales by the unrounded
191    /// factor, so what is drawn is a fraction of a pixel different from what is
192    /// reported — and it is the reported one that Wear stacks the next row
193    /// against and that the scroll indicator divides by. Advance an outward
194    /// walk by this plus the gap; see the module docs for why the walk stacks
195    /// scaled sizes at all.
196    pub reported_height: f32,
197    pub scale: f32,
198    pub alpha: f32,
199}
200
201/// Places a row the way a scaling list places one.
202///
203/// `top` is the outward walk's cursor for this row — the drawn bottom edge of
204/// the row between it and the centre, plus the gap — and `height` is its full,
205/// unscaled height. It is **not** the row's slot in a stack of full heights;
206/// the two agree only out to the centre row's immediate neighbours. See the
207/// module docs.
208///
209/// `density` is device pixels per unit; pass `0.0` to skip the pixel rounding
210/// and work in continuous coordinates.
211///
212/// Compose does this on integers, and two details of that survive into the
213/// result. The scaled height is rounded to a whole pixel before the row is
214/// pinned, and `convertToCenterOffset` halves a size with integer division
215/// while the offset it is compared against halves in floating point — so an odd
216/// pixel height carries exactly half a pixel that a float-only implementation
217/// loses.
218///
219/// Returns `None` for non-finite geometry or a negative height.
220pub fn place_row(viewport: f32, top: f32, height: f32, density: f32) -> Option<PlacedRow> {
221    place_row_with(ScalingParams::WEAR, viewport, top, height, density)
222}
223
224/// [`place_row`] with the ramp's six knobs supplied.
225pub fn place_row_with(
226    params: ScalingParams,
227    viewport: f32,
228    top: f32,
229    height: f32,
230    density: f32,
231) -> Option<PlacedRow> {
232    if !height.is_finite() || height < 0.0 || !density.is_finite() {
233        return None;
234    }
235    if density <= 0.0 {
236        let transform = scale_and_alpha_with(params, viewport, top, top + height)?;
237        let scaled = height * transform.scale;
238        return Some(PlacedRow {
239            top,
240            height: scaled,
241            // `roundToInt` has no meaning without a pixel grid to round onto.
242            reported_height: scaled,
243            scale: transform.scale,
244            alpha: transform.alpha,
245        });
246    }
247    let viewport_px = (viewport * density).round();
248    let top_px = (top * density).round();
249    let height_px = (height * density).round();
250    let transform = scale_and_alpha_with(params, viewport_px, top_px, top_px + height_px)?;
251    let scaled_px = (height_px * transform.scale).round();
252    // Wear's `isAboveLine`, on the same integers it uses: a row above the
253    // centre line keeps its BOTTOM edge, one below keeps its top.
254    let above = top_px + top_px + height_px < viewport_px;
255    let pinned = if above {
256        top_px + height_px - scaled_px
257    } else {
258        top_px
259    };
260    Some(PlacedRow {
261        top: (pinned + odd_pixel(height_px) - odd_pixel(scaled_px)) / density,
262        height: height_px * transform.scale / density,
263        reported_height: scaled_px / density,
264        scale: transform.scale,
265        alpha: transform.alpha,
266    })
267}
268
269/// Everything about a scaling list that is the same for all of its rows.
270///
271/// Held together rather than passed one by one because [`place_rows_with`]
272/// walks a run and every one of these is a property of the run, not of a row.
273#[derive(Clone, Copy, Debug, PartialEq)]
274pub struct RowRun {
275    /// The list's full height, which is what the ramp is stated against.
276    pub viewport: f32,
277    /// Which row the walk starts from — `ScalingLazyListState.centerItemIndex`.
278    pub anchor: usize,
279    /// Where the anchored row's own box starts. This is the one position the
280    /// unscaled stack and the walk always agree on: the anchored row is never
281    /// scaled, so its cursor and its slot are the same number.
282    pub anchor_top: f32,
283    /// `Arrangement.spacedBy`, between every pair of drawn boxes.
284    pub gap: f32,
285    /// Device pixels per unit; `0.0` works in continuous coordinates.
286    pub density: f32,
287}
288
289/// Places a whole run of rows the way a scaling list places one, walking
290/// **outward from the anchored row**.
291///
292/// This is the shape the rule actually has. [`place_row`] answers for one row
293/// given its cursor, and the cursor for the row after it is
294/// `PlacedRow::reported_height + gap` further out — never the full height — so
295/// a per-row call cannot state the rule on its own and a caller that stacks
296/// full heights gets a list that drifts. See the module docs.
297///
298/// `out` is cleared first and comes back one entry per height, in list order.
299pub fn place_rows_with(
300    params: ScalingParams,
301    run: RowRun,
302    heights: &[f32],
303    out: &mut Vec<PlacedRow>,
304) {
305    out.clear();
306    if heights.is_empty() {
307        return;
308    }
309    let anchor = run.anchor.min(heights.len() - 1);
310    let unscaled = |top: f32, height: f32| PlacedRow {
311        top,
312        height,
313        reported_height: height,
314        scale: 1.0,
315        alpha: 1.0,
316    };
317    out.resize(heights.len(), unscaled(0.0, 0.0));
318    let place = |top: f32, height: f32| {
319        place_row_with(params, run.viewport, top, height, run.density)
320            .unwrap_or_else(|| unscaled(top, height))
321    };
322
323    let mut cursor = run.anchor_top;
324    for (index, &height) in heights.iter().enumerate().skip(anchor) {
325        let row = place(cursor, height);
326        cursor += row.reported_height + run.gap;
327        out[index] = row;
328    }
329    // Upward the cursor is the next row's BOTTOM, and the ramp is still read
330    // off the row's full box hanging from it.
331    let mut bottom = run.anchor_top;
332    for index in (0..anchor).rev() {
333        let height = heights[index];
334        bottom -= run.gap;
335        let row = place(bottom - height, height);
336        bottom -= row.reported_height;
337        out[index] = row;
338    }
339}
340
341/// [`place_rows_with`] under Wear's own ramp.
342pub fn place_rows(run: RowRun, heights: &[f32], out: &mut Vec<PlacedRow>) {
343    place_rows_with(ScalingParams::WEAR, run, heights, out)
344}
345
346/// A row's unscaled place in the column: where it would sit and how tall it is
347/// with nothing scaled.
348///
349/// This is the coordinate space the whole module works in. [`place_row`] turns
350/// a slot into the transformed rectangle that is actually drawn; the slot
351/// itself never moves because a row shrank.
352#[derive(Clone, Copy, Debug, PartialEq)]
353pub struct Slot {
354    pub top: f32,
355    pub height: f32,
356}
357
358impl Slot {
359    pub fn centre(self) -> f32 {
360        self.top + self.height * 0.5
361    }
362
363    pub fn bottom(self) -> f32 {
364        self.top + self.height
365    }
366}
367
368/// Stacks row heights into slots, `gap` apart, starting at zero.
369///
370/// The stack is of FULL heights — that is the invariant the whole scaling model
371/// rests on, and stacking scaled heights instead is the mistake this module
372/// exists to prevent.
373pub fn stack_into(heights: impl IntoIterator<Item = f32>, gap: f32, out: &mut Vec<Slot>) {
374    out.clear();
375    let mut cursor = 0.0;
376    for height in heights {
377        out.push(Slot {
378            top: cursor,
379            height,
380        });
381        cursor += height + gap;
382    }
383}
384
385/// Which item the list holds on its centre line, and by how much it is offset.
386///
387/// This is `ScalingLazyListState`'s coordinate pair — `centerItemIndex` plus
388/// `centerItemScrollOffset` — under the default `ScalingLazyListAnchorType.ItemCenter`,
389/// where the anchored point is the item's centre rather than its top edge.
390/// A positive `offset` scrolls the content up, the same sign as a scroll
391/// position.
392#[derive(Clone, Copy, Debug, PartialEq)]
393pub struct CentreAnchor {
394    pub index: usize,
395    pub offset: f32,
396}
397
398impl Default for CentreAnchor {
399    /// `rememberScalingLazyListState()`'s own default: the second item, centred.
400    fn default() -> Self {
401        Self {
402            index: 1,
403            offset: 0.0,
404        }
405    }
406}
407
408/// A length moved onto the whole device pixel Compose would give it.
409///
410/// Compose's layout is integral — `Dp.roundToPx()` runs before anything is
411/// measured and children are placed at an `IntOffset` — and Kotlin's
412/// `roundToInt` sends an exact half **up**, not away from zero. Rust's
413/// `f32::round` disagrees on exactly the negative halves, which is the case a
414/// scroll offset reaches.
415pub fn round_to_px(value: f32, density: f32) -> f32 {
416    if density <= 0.0 || !density.is_finite() || !value.is_finite() {
417        return value;
418    }
419    (value * density + 0.5).floor() / density
420}
421
422/// How far the whole column must move so the anchored item sits on the centre
423/// line — Wear's `autoCentering`, as one shift rather than two spacers.
424///
425/// Wear expresses this by injecting a `Spacer` before and after the content
426/// (see [`auto_centring_spacers`]), which is the same arithmetic seen from the
427/// other side: with the leading spacer un-clamped, the content offset it
428/// produces is exactly this shift. Returning the shift lets a caller place rows
429/// directly instead of measuring two phantom items.
430///
431/// The result is rounded to a whole device pixel, because the `LazyColumn`
432/// underneath holds its scroll position as a whole number of pixels: a float
433/// delta is rounded before it is applied and the remainder carried, so every
434/// item top stays integral. Rounding once here does that for the whole column.
435/// Pass `density <= 0.0` to work in continuous coordinates.
436pub fn centre_offset(slots: &[Slot], viewport: f32, anchor: CentreAnchor, density: f32) -> f32 {
437    let Some(slot) = slots.get(anchor.index).or_else(|| slots.last()) else {
438        return 0.0;
439    };
440    round_to_px(viewport * 0.5 - slot.centre() - anchor.offset, density)
441}
442
443/// [`centre_offset`] for a caller that holds its scroll position as a
444/// fractional item index rather than an index and a pixel offset.
445///
446/// `scroll` of `2.5` centres the point halfway between the third and fourth
447/// items' centres. This is the shape an app that scrolls by whole rows wants,
448/// and it interpolates between item *centres* rather than tops so a tall row
449/// next to a short one does not accelerate through the middle.
450pub fn centre_offset_at(slots: &[Slot], viewport: f32, scroll: f32, density: f32) -> f32 {
451    if slots.is_empty() {
452        return 0.0;
453    }
454    let scroll = if scroll.is_finite() { scroll } else { 0.0 };
455    let whole = (scroll.floor().max(0.0) as usize).min(slots.len() - 1);
456    let fraction = (scroll - whole as f32).clamp(0.0, 1.0);
457    let mut anchor = slots[whole].centre();
458    if let Some(next) = slots.get(whole + 1) {
459        anchor += (next.centre() - anchor) * fraction;
460    }
461    round_to_px(viewport * 0.5 - anchor, density)
462}
463
464/// Moves every slot by `offset`.
465pub fn shift(slots: &mut [Slot], offset: f32) {
466    for slot in slots.iter_mut() {
467        slot.top += offset;
468    }
469}
470
471/// The two spacer heights Wear's `autoCentering` injects around the content.
472///
473/// Wear does not shift the column; it inserts a `Spacer` item before all
474/// content and another after it, which is why `totalItemsCount` is two less
475/// than the `LazyColumn`'s and every public index is one higher. Both are
476/// reproduced here because the numbers differ from the plain shift in two
477/// places that show on screen:
478///
479/// - the leading spacer is clamped at zero, so the anchored item cannot be
480///   pushed *below* the centre line by a short list;
481/// - the centre line is `floor(viewport / 2)` on an integer pixel grid, so an
482///   odd viewport gives its spare pixel to the trailing spacer.
483///
484/// `viewport` and the slot geometry are in device pixels here, not points —
485/// that is the space Wear does this arithmetic in.
486pub fn auto_centring_spacers(slots: &[Slot], viewport_px: f32, anchor: CentreAnchor) -> (f32, f32) {
487    let leading = slots
488        .get(anchor.index)
489        .or_else(|| slots.last())
490        .map(|slot| leading_auto_centring_spacer(viewport_px, slot.centre(), anchor.offset))
491        .unwrap_or(0.0);
492    let trailing = slots
493        .last()
494        .map(|slot| trailing_auto_centring_spacer(viewport_px, slot.height))
495        .unwrap_or(0.0);
496    (leading, trailing)
497}
498
499/// The leading `autoCentering` spacer, for a caller holding the anchored row
500/// rather than the stack it came from.
501///
502/// `anchor_centre_px` is that row's centre measured from the top of the
503/// content, which is where [`stack_into`] puts it. See
504/// [`auto_centring_spacers`] for what the two spacers are and why the clamp
505/// and the floored centre line matter.
506pub fn leading_auto_centring_spacer(
507    viewport_px: f32,
508    anchor_centre_px: f32,
509    anchor_offset: f32,
510) -> f32 {
511    ((viewport_px * 0.5).floor() - anchor_offset - anchor_centre_px).max(0.0)
512}
513
514/// The trailing `autoCentering` spacer, which depends only on the last row's
515/// height: `unadjustedSizeBelowOffsetPoint` under `ItemCenter` is half of it.
516pub fn trailing_auto_centring_spacer(viewport_px: f32, last_height_px: f32) -> f32 {
517    (viewport_px - (viewport_px * 0.5).floor() - last_height_px * 0.5).max(0.0)
518}
519
520/// The stretch of content a scaling list can hold on its centre line.
521///
522/// Both ends are content coordinates — the same space [`stack_into`] stacks
523/// slots in — and the pair is what a scroll position has to be kept inside.
524///
525/// **It is not "the anchored row centred" to "the last row centred".** The
526/// `LazyColumn` underneath takes its `contentPadding` OUTSIDE both auto-centring
527/// spacers, so the padding is scroll the list can spend at each end. At the
528/// bottom that means the last row settles `after_padding` **above** the centre
529/// line rather than on it; at the top the anchored row can be pulled
530/// `before_padding` **below** it. Wear says the same thing from the other side
531/// in `ScalingLazyListState.scrollToItem`, which scrolls the `LazyColumn` to
532/// `beforeContentPaddingPx - viewportCenterLinePx` to put a row on the line —
533/// so the list is already `before_padding` in from its own top when it opens,
534/// and a port that stops at the two centred rows cannot reach either end.
535///
536/// Clamping at the two centred rows costs behaviour and not only pixels: it is
537/// the difference between a user reaching the last row of a settings list and
538/// not reaching it.
539///
540/// Both spacers are clamped at zero in Wear (see [`auto_centring_spacers`]), and
541/// this states the travel for a list where neither clamp bit — a list long
542/// enough to scroll with a leading spacer left. On one clamped at either end the
543/// true travel is shorter at that end.
544pub fn anchor_travel(
545    anchor_centre: f32,
546    last_centre: f32,
547    before_padding: f32,
548    after_padding: f32,
549) -> (f32, f32) {
550    let end = last_centre + after_padding;
551    let start = anchor_centre - before_padding;
552    (start.min(end), end)
553}
554
555/// Half a pixel when a pixel height is odd, nothing when it is even — what
556/// Compose's integer halving leaves behind beside its floating-point one.
557fn odd_pixel(pixels: f32) -> f32 {
558    let half = pixels * 0.5;
559    half - half.floor()
560}
561
562fn inverse_lerp(start: f32, stop: f32, value: f32) -> f32 {
563    ((value - start) / (stop - start)).clamp(0.0, 1.0)
564}
565
566/// Wear's transition easing, `CubicBezierEasing(0.3, 0.0, 0.7, 1.0)`.
567///
568/// A Compose easing curve is parametric, so the answer is the curve's y at the
569/// parameter whose x is `fraction`. Twelve bisections put the result inside
570/// 1/4096, which is far below a pixel at any watch size.
571fn ease(x: f32) -> f32 {
572    let x = x.clamp(0.0, 1.0);
573    let mut low = 0.0f32;
574    let mut high = 1.0f32;
575    let mut t = x;
576    for _ in 0..12 {
577        let value = bezier(t, 0.3, 0.7);
578        if value < x {
579            low = t;
580        } else {
581            high = t;
582        }
583        t = (low + high) * 0.5;
584    }
585    bezier(t, 0.0, 1.0)
586}
587
588fn bezier(t: f32, first: f32, second: f32) -> f32 {
589    let inverse = 1.0 - t;
590    3.0 * inverse * inverse * t * first + 3.0 * inverse * t * t * second + t * t * t
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596
597    const VIEWPORT: f32 = 227.0;
598
599    #[test]
600    fn a_row_in_the_middle_is_left_alone() {
601        let middle = scale_and_alpha(VIEWPORT, VIEWPORT * 0.45, VIEWPORT * 0.55).unwrap();
602        assert_eq!(middle, ScaleAlpha::UNCHANGED);
603    }
604
605    #[test]
606    fn a_row_at_the_edge_is_shrunk_and_faded_together() {
607        let edge = scale_and_alpha(VIEWPORT, 0.0, 20.0).unwrap();
608        assert!(edge.scale < 1.0 && edge.scale >= EDGE_SCALE, "{edge:?}");
609        assert!(edge.alpha < 1.0 && edge.alpha >= EDGE_ALPHA, "{edge:?}");
610        // Both run to their limits together, so a row never fades without
611        // shrinking or the reverse.
612        let top = scale_and_alpha(VIEWPORT, 0.0, 0.0).unwrap();
613        assert!((top.scale - EDGE_SCALE).abs() < 1e-3, "{top:?}");
614        assert!((top.alpha - EDGE_ALPHA).abs() < 1e-3, "{top:?}");
615    }
616
617    #[test]
618    fn the_two_edges_treat_a_row_the_same() {
619        let height = 40.0;
620        let near_top = scale_and_alpha(VIEWPORT, 8.0, 8.0 + height).unwrap();
621        let near_bottom =
622            scale_and_alpha(VIEWPORT, VIEWPORT - 8.0 - height, VIEWPORT - 8.0).unwrap();
623        assert!((near_top.scale - near_bottom.scale).abs() < 1e-5);
624        assert!((near_top.alpha - near_bottom.alpha).abs() < 1e-5);
625    }
626
627    #[test]
628    fn a_taller_row_starts_shrinking_further_from_the_edge() {
629        // The transition band grows with row height. Isolating that needs two
630        // rows at the SAME distance from an edge: anchor both near the bottom,
631        // where `edge` is measured from the top edge and so does not move when
632        // the height does. The taller row has the wider band, so the same
633        // distance is a larger fraction of it and it shrinks more.
634        let top = VIEWPORT - 10.0;
635        let short = scale_and_alpha(VIEWPORT, top, top + VIEWPORT * 0.2).unwrap();
636        let tall = scale_and_alpha(VIEWPORT, top, top + VIEWPORT * 0.62).unwrap();
637        assert!(tall.scale < short.scale, "short {short:?} tall {tall:?}");
638    }
639
640    #[test]
641    fn a_row_is_placed_from_the_full_heights_above_it_not_the_scaled_ones() {
642        // Two rows of the same full height at the same unscaled offsets must
643        // land where those offsets say, however much the first one shrank.
644        let first = place_row(VIEWPORT, 0.0, 50.0, 2.0).unwrap();
645        let second = place_row(VIEWPORT, 50.0, 50.0, 2.0).unwrap();
646        assert!(first.scale < 1.0, "the first row is at the edge: {first:?}");
647        // The second row's position is not pushed up by the first row shrinking.
648        assert!(second.top >= 49.0, "{second:?}");
649    }
650
651    #[test]
652    fn a_row_above_the_centre_line_keeps_its_bottom_edge() {
653        // Above the line the transform origin is the bottom, so shrinking pulls
654        // the top down; below the line the top is pinned and the bottom rises.
655        let above = place_row(VIEWPORT, 4.0, 50.0, 2.0).unwrap();
656        assert!(above.scale < 1.0, "{above:?}");
657        assert!(
658            above.top > 4.0,
659            "shrinking should pull the top down: {above:?}"
660        );
661
662        let below = place_row(VIEWPORT, VIEWPORT - 54.0, 50.0, 2.0).unwrap();
663        assert!(below.scale < 1.0, "{below:?}");
664        assert!(
665            (below.top - (VIEWPORT - 54.0)).abs() < 0.6,
666            "the top is pinned below the line: {below:?}"
667        );
668    }
669
670    #[test]
671    fn an_odd_pixel_height_carries_the_half_pixel_composes_integer_halving_leaves() {
672        // 25 units at density 2 is a 50px row — even. 25.5 is 51px — odd, and
673        // that half pixel is exactly what a float-only implementation drops.
674        assert_eq!(odd_pixel(50.0), 0.0);
675        assert_eq!(odd_pixel(51.0), 0.5);
676        let odd = place_row(VIEWPORT, 3.0, 25.5, 2.0).unwrap();
677        assert!(odd.scale < 1.0, "needs to be in the scaled band: {odd:?}");
678    }
679
680    #[test]
681    fn a_density_of_zero_falls_back_to_continuous_placement_instead_of_dividing_by_it() {
682        let placed = place_row(VIEWPORT, 10.0, 50.0, 0.0).unwrap();
683        assert!(
684            placed.top.is_finite() && placed.height.is_finite(),
685            "{placed:?}"
686        );
687        assert_eq!(placed.top, 10.0);
688        let negative = place_row(VIEWPORT, 10.0, 50.0, -2.0).unwrap();
689        assert_eq!(negative, placed, "a nonsense density is not a crash");
690    }
691
692    #[test]
693    fn an_empty_viewport_leaves_everything_alone_rather_than_dividing_by_it() {
694        assert_eq!(scale_and_alpha(0.0, 0.0, 10.0), Some(ScaleAlpha::UNCHANGED));
695        assert_eq!(
696            scale_and_alpha(-5.0, 0.0, 10.0),
697            Some(ScaleAlpha::UNCHANGED)
698        );
699    }
700
701    #[test]
702    fn invalid_geometry_is_rejected_instead_of_producing_nan() {
703        assert_eq!(scale_and_alpha(f32::NAN, 0.0, 10.0), None);
704        assert_eq!(scale_and_alpha(VIEWPORT, 10.0, 9.0), None);
705        assert_eq!(place_row(VIEWPORT, 0.0, -1.0, 2.0), None);
706        assert_eq!(place_row(VIEWPORT, 0.0, 10.0, f32::INFINITY), None);
707    }
708
709    #[test]
710    fn the_easing_is_monotonic_and_spans_the_whole_range() {
711        assert!((ease(0.0) - 0.0).abs() < 1e-3, "{}", ease(0.0));
712        assert!((ease(1.0) - 1.0).abs() < 1e-3, "{}", ease(1.0));
713        let mut previous = -1.0;
714        for step in 0..=20 {
715            let value = ease(step as f32 / 20.0);
716            assert!(value >= previous - 1e-4, "not monotonic at {step}");
717            previous = value;
718        }
719    }
720
721    #[test]
722    fn the_easing_matches_the_current_wear_compose_curve() {
723        assert!((ease(0.25) - 0.166_779).abs() < 1e-3, "{}", ease(0.25));
724    }
725
726    #[test]
727    fn the_default_scaling_params_are_the_constants_the_module_documents() {
728        let params = ScalingParams::default();
729        assert_eq!(params.edge_scale, EDGE_SCALE);
730        assert_eq!(params.edge_alpha, EDGE_ALPHA);
731        assert_eq!(params.min_element_height, MIN_ELEMENT_HEIGHT);
732        assert_eq!(params.max_element_height, MAX_ELEMENT_HEIGHT);
733        assert_eq!(params.min_transition_area, MIN_TRANSITION_AREA);
734        assert_eq!(params.max_transition_area, MAX_TRANSITION_AREA);
735        // And the parameterised entry points agree with the fixed ones.
736        assert_eq!(
737            scale_and_alpha_with(params, VIEWPORT, 0.0, 20.0),
738            scale_and_alpha(VIEWPORT, 0.0, 20.0)
739        );
740        assert_eq!(
741            place_row_with(params, VIEWPORT, 4.0, 50.0, 2.0),
742            place_row(VIEWPORT, 4.0, 50.0, 2.0)
743        );
744    }
745
746    #[test]
747    fn reduced_motion_turns_the_ramp_off_rather_than_damping_it() {
748        let params = ScalingParams::default().reduced_motion();
749        let edge = scale_and_alpha_with(params, VIEWPORT, 0.0, 0.0).unwrap();
750        assert_eq!(edge, ScaleAlpha::UNCHANGED);
751    }
752
753    #[test]
754    fn the_supplied_params_reach_the_pixel_path_and_not_only_the_continuous_one() {
755        // `place_row_with` used to hand the pixel branch the Wear defaults and
756        // ignore its own argument, so a `reduced_motion` list was identical to
757        // a scaling one on every real display and only differed at density 0 —
758        // which is the one case no device is in.
759        let params = ScalingParams::default().reduced_motion();
760        let still = place_row_with(params, VIEWPORT, 4.0, 50.0, 2.0).unwrap();
761        assert_eq!(still.scale, 1.0, "{still:?}");
762        assert_eq!(still.alpha, 1.0, "{still:?}");
763        assert_eq!(still.top, 4.0, "an unscaled row is not pinned anywhere");
764        // And the Wear defaults still scale the same row, so the test is not
765        // passing because the row was out of the band.
766        assert!(place_row(VIEWPORT, 4.0, 50.0, 2.0).unwrap().scale < 1.0);
767    }
768
769    #[test]
770    fn a_row_is_placed_against_the_scaled_size_of_the_row_between_it_and_the_centre() {
771        // `ScalingLazyListState.layoutInfo` walks outward from the centre item
772        // with a cursor that advances by `ScalingLazyListItemInfo.size`, which
773        // is the SCALED size. Two rows below the anchor, that cursor has
774        // already lost whatever the first one shrank by.
775        let viewport = 192.0;
776        let density = 2.0;
777        let (gap, height) = (4.0, 52.0);
778        let anchor_top = 70.0;
779
780        let anchor = place_row(viewport, anchor_top, height, density).unwrap();
781        assert_eq!(anchor.scale, 1.0, "the anchored row is not scaled");
782        assert_eq!(
783            anchor.reported_height, height,
784            "so it reports its full height"
785        );
786
787        // First row below: both accounts agree, because the anchor is unscaled.
788        let first_top = anchor_top + anchor.reported_height + gap;
789        assert_eq!(first_top, anchor_top + height + gap);
790        let first = place_row(viewport, first_top, height, density).unwrap();
791        assert!(first.scale < 1.0, "{first:?}");
792        assert!(
793            first.reported_height < height,
794            "and it reports less than its full height: {first:?}"
795        );
796
797        // Second row below: the cursor and the full-height stack part company.
798        let second_top = first_top + first.reported_height + gap;
799        let stacked_top = first_top + height + gap;
800        assert!(
801            second_top < stacked_top,
802            "cursor {second_top} vs full stack {stacked_top}"
803        );
804        let by_cursor = place_row(viewport, second_top, height, density).unwrap();
805        let by_stack = place_row(viewport, stacked_top, height, density).unwrap();
806        assert_ne!(by_cursor.top, by_stack.top);
807    }
808
809    #[test]
810    fn the_reported_height_is_the_rounded_one_and_the_drawn_height_is_not() {
811        // 51 device pixels at some scale under one: the layout reports a whole
812        // pixel and the graphics layer draws the fraction.
813        let placed = place_row(VIEWPORT, 3.0, 25.5, 2.0).unwrap();
814        assert!(
815            placed.scale < 1.0,
816            "needs to be in the scaled band: {placed:?}"
817        );
818        assert_eq!(
819            placed.reported_height * 2.0,
820            (placed.reported_height * 2.0).round(),
821            "the reported height is a whole pixel: {placed:?}"
822        );
823        assert_ne!(placed.reported_height, placed.height);
824    }
825
826    #[test]
827    fn a_stack_puts_full_heights_a_gap_apart() {
828        let mut slots = Vec::new();
829        stack_into([10.0, 20.0, 30.0], 4.0, &mut slots);
830        assert_eq!(
831            slots,
832            vec![
833                Slot {
834                    top: 0.0,
835                    height: 10.0
836                },
837                Slot {
838                    top: 14.0,
839                    height: 20.0
840                },
841                Slot {
842                    top: 38.0,
843                    height: 30.0
844                },
845            ]
846        );
847        assert_eq!(slots[1].centre(), 24.0);
848        assert_eq!(slots[2].bottom(), 68.0);
849    }
850
851    #[test]
852    fn the_centre_anchor_puts_the_anchored_items_centre_on_the_centre_line() {
853        let mut slots = Vec::new();
854        stack_into([40.0, 60.0, 40.0], 4.0, &mut slots);
855        // Item 1 spans 44..104, centre 74. The viewport centre is 113.5, which
856        // at density 2 is a whole pixel, so the shift is exact.
857        let offset = centre_offset(&slots, VIEWPORT, CentreAnchor::default(), 2.0);
858        shift(&mut slots, offset);
859        assert!(
860            (slots[1].centre() - VIEWPORT * 0.5).abs() < 1e-4,
861            "{slots:?}"
862        );
863    }
864
865    #[test]
866    fn a_scroll_offset_moves_the_content_up() {
867        let mut slots = Vec::new();
868        stack_into([40.0, 60.0, 40.0], 4.0, &mut slots);
869        let still = centre_offset(&slots, VIEWPORT, CentreAnchor::default(), 0.0);
870        let scrolled = centre_offset(
871            &slots,
872            VIEWPORT,
873            CentreAnchor {
874                index: 1,
875                offset: 10.0,
876            },
877            0.0,
878        );
879        assert!((still - scrolled - 10.0).abs() < 1e-4, "{still} {scrolled}");
880    }
881
882    #[test]
883    fn a_fractional_scroll_travels_between_item_centres_not_item_tops() {
884        let mut slots = Vec::new();
885        // A short row beside a tall one: interpolating tops would move the
886        // anchor by the first row's height, centres by the mean of the two.
887        stack_into([20.0, 100.0], 0.0, &mut slots);
888        let start = centre_offset_at(&slots, VIEWPORT, 0.0, 0.0);
889        let end = centre_offset_at(&slots, VIEWPORT, 1.0, 0.0);
890        let middle = centre_offset_at(&slots, VIEWPORT, 0.5, 0.0);
891        assert!((middle - (start + end) * 0.5).abs() < 1e-4);
892        // And the endpoints agree with the index-and-offset form.
893        assert_eq!(
894            start,
895            centre_offset(
896                &slots,
897                VIEWPORT,
898                CentreAnchor {
899                    index: 0,
900                    offset: 0.0
901                },
902                0.0
903            )
904        );
905    }
906
907    #[test]
908    fn a_scroll_past_either_end_clamps_instead_of_running_off() {
909        let mut slots = Vec::new();
910        stack_into([20.0, 20.0], 4.0, &mut slots);
911        assert_eq!(
912            centre_offset_at(&slots, VIEWPORT, -5.0, 0.0),
913            centre_offset_at(&slots, VIEWPORT, 0.0, 0.0)
914        );
915        assert_eq!(
916            centre_offset_at(&slots, VIEWPORT, 9.0, 0.0),
917            centre_offset_at(&slots, VIEWPORT, 1.0, 0.0)
918        );
919        assert_eq!(centre_offset_at(&[], VIEWPORT, 0.0, 2.0), 0.0);
920        assert_eq!(
921            centre_offset(&[], VIEWPORT, CentreAnchor::default(), 2.0),
922            0.0
923        );
924    }
925
926    #[test]
927    fn rounding_a_length_to_a_pixel_sends_an_exact_half_up_the_way_kotlin_does() {
928        // 0.25 at density 2 is exactly half a pixel.
929        assert_eq!(round_to_px(0.25, 2.0), 0.5);
930        assert_eq!(round_to_px(-0.25, 2.0), 0.0);
931        // Rust's own rounding sends the negative half the other way, which is
932        // the disagreement this helper exists to settle.
933        assert_eq!((-0.5f32).round(), -1.0);
934        assert_eq!(round_to_px(0.3, 0.0), 0.3);
935        assert!(round_to_px(f32::NAN, 2.0).is_nan());
936    }
937
938    #[test]
939    fn the_shift_and_the_two_spacers_are_the_same_arithmetic_seen_from_two_sides() {
940        // In pixels, on an even viewport, with the anchored item far enough
941        // down that the leading spacer is not clamped.
942        let viewport_px = 454.0;
943        let mut slots = Vec::new();
944        stack_into([96.0, 104.0, 104.0], 8.0, &mut slots);
945        let anchor = CentreAnchor::default();
946        let (leading, _) = auto_centring_spacers(&slots, viewport_px, anchor);
947        let offset = centre_offset(&slots, viewport_px, anchor, 1.0);
948        assert!((leading - offset).abs() < 1e-4, "{leading} vs {offset}");
949    }
950
951    #[test]
952    fn the_leading_spacer_never_pushes_the_anchor_below_the_centre_line() {
953        // One short item: the plain shift would be positive and large, and the
954        // clamp is what stops the list from starting scrolled.
955        let mut slots = Vec::new();
956        stack_into([600.0], 8.0, &mut slots);
957        let anchor = CentreAnchor::default();
958        let (leading, trailing) = auto_centring_spacers(&slots, 454.0, anchor);
959        assert_eq!(leading, 0.0, "a tall first item needs no leading spacer");
960        assert!(trailing >= 0.0, "{trailing}");
961    }
962
963    #[test]
964    fn the_content_padding_is_travel_at_both_ends_and_not_blank_beyond_them() {
965        let mut slots = Vec::new();
966        stack_into([96.0, 104.0, 104.0, 104.0], 8.0, &mut slots);
967        let anchor = slots[1].centre();
968        let last = slots[3].centre();
969        let (start, end) = anchor_travel(anchor, last, 68.0, 68.0);
970        // The anchored row can be pulled below the centre line by the padding,
971        // and the last row can be pushed above it by the same.
972        assert_eq!(start, anchor - 68.0);
973        assert_eq!(end, last + 68.0);
974        // Which is 136 px more travel than stopping at the two centred rows.
975        assert_eq!((end - start) - (last - anchor), 136.0);
976    }
977
978    #[test]
979    fn a_list_with_nowhere_to_go_does_not_travel_backwards() {
980        // One row, so both ends are the same row's centre. The padding must not
981        // hand back a range whose start is past its end.
982        let mut slots = Vec::new();
983        stack_into([104.0], 8.0, &mut slots);
984        let centre = slots[0].centre();
985        let (start, end) = anchor_travel(centre, centre, 68.0, 0.0);
986        assert!(start <= end, "{start} {end}");
987        assert_eq!(end, centre);
988    }
989
990    #[test]
991    fn an_odd_viewport_gives_its_spare_pixel_to_the_trailing_spacer() {
992        let mut slots = Vec::new();
993        stack_into([100.0, 100.0], 8.0, &mut slots);
994        let anchor = CentreAnchor::default();
995        let (_, odd) = auto_centring_spacers(&slots, 455.0, anchor);
996        let (_, even) = auto_centring_spacers(&slots, 454.0, anchor);
997        assert_eq!(odd - even, 1.0, "odd {odd} even {even}");
998    }
999}