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    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/// Where a row ends up once the list has scaled it.
177#[derive(Clone, Copy, Debug, PartialEq)]
178pub struct PlacedRow {
179    /// Top edge after the transform, in the unit `top` was given in.
180    pub top: f32,
181    /// Height after the transform.
182    pub height: f32,
183    /// The height the layout **reports** for this row:
184    /// `ScalingLazyListItemInfo.size`, which is `roundToInt(size * scale)`.
185    ///
186    /// It is not [`Self::height`]. The graphics layer scales by the unrounded
187    /// factor, so what is drawn is a fraction of a pixel different from what is
188    /// reported — and it is the reported one that Wear stacks the next row
189    /// against and that the scroll indicator divides by. Advance an outward
190    /// walk by this plus the gap; see the module docs for why the walk stacks
191    /// scaled sizes at all.
192    pub reported_height: f32,
193    pub scale: f32,
194    pub alpha: f32,
195}
196
197/// Places a row the way a scaling list places one.
198///
199/// `top` is the outward walk's cursor for this row — the drawn bottom edge of
200/// the row between it and the centre, plus the gap — and `height` is its full,
201/// unscaled height. It is **not** the row's slot in a stack of full heights;
202/// the two agree only out to the centre row's immediate neighbours. See the
203/// module docs.
204///
205/// `density` is device pixels per unit; pass `0.0` to skip the pixel rounding
206/// and work in continuous coordinates.
207///
208/// Compose does this on integers, and two details of that survive into the
209/// result. The scaled height is rounded to a whole pixel before the row is
210/// pinned, and `convertToCenterOffset` halves a size with integer division
211/// while the offset it is compared against halves in floating point — so an odd
212/// pixel height carries exactly half a pixel that a float-only implementation
213/// loses.
214///
215/// Returns `None` for non-finite geometry or a negative height.
216pub 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
220/// [`place_row`] with the ramp's six knobs supplied.
221pub 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/// Everything about a scaling list that is the same for all of its rows.
263///
264/// Held together rather than passed one by one because [`place_rows_with`]
265/// walks a run and every one of these is a property of the run, not of a row.
266#[derive(Clone, Copy, Debug, PartialEq)]
267pub struct RowRun {
268    /// The list's full height, which is what the ramp is stated against.
269    pub viewport: f32,
270    /// Which row the walk starts from — `ScalingLazyListState.centerItemIndex`.
271    pub anchor: usize,
272    /// Where the anchored row's own box starts. This is the one position the
273    /// unscaled stack and the walk always agree on: the anchored row is never
274    /// scaled, so its cursor and its slot are the same number.
275    pub anchor_top: f32,
276    /// `Arrangement.spacedBy`, between every pair of drawn boxes.
277    pub gap: f32,
278    /// Device pixels per unit; `0.0` works in continuous coordinates.
279    pub density: f32,
280}
281
282/// Places a whole run of rows the way a scaling list places one, walking
283/// **outward from the anchored row**.
284///
285/// This is the shape the rule actually has. [`place_row`] answers for one row
286/// given its cursor, and the cursor for the row after it is
287/// `PlacedRow::reported_height + gap` further out — never the full height — so
288/// a per-row call cannot state the rule on its own and a caller that stacks
289/// full heights gets a list that drifts. See the module docs.
290///
291/// `out` is cleared first and comes back one entry per height, in list order.
292pub 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
332/// [`place_rows_with`] under Wear's own ramp.
333pub fn place_rows(run: RowRun, heights: &[f32], out: &mut Vec<PlacedRow>) {
334    place_rows_with(ScalingParams::WEAR, run, heights, out)
335}
336
337/// A row's unscaled place in the column: where it would sit and how tall it is
338/// with nothing scaled.
339///
340/// This is the coordinate space the whole module works in. [`place_row`] turns
341/// a slot into the transformed rectangle that is actually drawn; the slot
342/// itself never moves because a row shrank.
343#[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
359/// Stacks row heights into slots, `gap` apart, starting at zero.
360///
361/// The stack is of FULL heights — that is the invariant the whole scaling model
362/// rests on, and stacking scaled heights instead is the mistake this module
363/// exists to prevent.
364pub 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/// Which item the list holds on its centre line, and by how much it is offset.
377///
378/// This is `ScalingLazyListState`'s coordinate pair — `centerItemIndex` plus
379/// `centerItemScrollOffset` — under the default `ScalingLazyListAnchorType.ItemCenter`,
380/// where the anchored point is the item's centre rather than its top edge.
381/// A positive `offset` scrolls the content up, the same sign as a scroll
382/// position.
383#[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
398/// A length moved onto the whole device pixel Compose would give it.
399///
400/// Compose's layout is integral — `Dp.roundToPx()` runs before anything is
401/// measured and children are placed at an `IntOffset` — and Kotlin's
402/// `roundToInt` sends an exact half **up**, not away from zero. Rust's
403/// `f32::round` disagrees on exactly the negative halves, which is the case a
404/// scroll offset reaches.
405pub 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
412/// How far the whole column must move so the anchored item sits on the centre
413/// line — Wear's `autoCentering`, as one shift rather than two spacers.
414///
415/// Wear expresses this by injecting a `Spacer` before and after the content
416/// (see [`auto_centring_spacers`]), which is the same arithmetic seen from the
417/// other side: with the leading spacer un-clamped, the content offset it
418/// produces is exactly this shift. Returning the shift lets a caller place rows
419/// directly instead of measuring two phantom items.
420///
421/// The result is rounded to a whole device pixel, because the `LazyColumn`
422/// underneath holds its scroll position as a whole number of pixels: a float
423/// delta is rounded before it is applied and the remainder carried, so every
424/// item top stays integral. Rounding once here does that for the whole column.
425/// Pass `density <= 0.0` to work in continuous coordinates.
426pub 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
433/// [`centre_offset`] for a caller that holds its scroll position as a
434/// fractional item index rather than an index and a pixel offset.
435///
436/// `scroll` of `2.5` centres the point halfway between the third and fourth
437/// items' centres. This is the shape an app that scrolls by whole rows wants,
438/// and it interpolates between item *centres* rather than tops so a tall row
439/// next to a short one does not accelerate through the middle.
440pub 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
454/// Moves every slot by `offset`.
455pub fn shift(slots: &mut [Slot], offset: f32) {
456    for slot in slots.iter_mut() {
457        slot.top += offset;
458    }
459}
460
461/// The two spacer heights Wear's `autoCentering` injects around the content.
462///
463/// Wear does not shift the column; it inserts a `Spacer` item before all
464/// content and another after it, which is why `totalItemsCount` is two less
465/// than the `LazyColumn`'s and every public index is one higher. Both are
466/// reproduced here because the numbers differ from the plain shift in two
467/// places that show on screen:
468///
469/// - the leading spacer is clamped at zero, so the anchored item cannot be
470///   pushed *below* the centre line by a short list;
471/// - the centre line is `floor(viewport / 2)` on an integer pixel grid, so an
472///   odd viewport gives its spare pixel to the trailing spacer.
473///
474/// `viewport` and the slot geometry are in device pixels here, not points —
475/// that is the space Wear does this arithmetic in.
476pub 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
489/// The leading `autoCentering` spacer, for a caller holding the anchored row
490/// rather than the stack it came from.
491///
492/// `anchor_centre_px` is that row's centre measured from the top of the
493/// content, which is where [`stack_into`] puts it. See
494/// [`auto_centring_spacers`] for what the two spacers are and why the clamp
495/// and the floored centre line matter.
496pub 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
504/// The trailing `autoCentering` spacer, which depends only on the last row's
505/// height: `unadjustedSizeBelowOffsetPoint` under `ItemCenter` is half of it.
506pub 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
510/// The stretch of content a scaling list can hold on its centre line.
511///
512/// Both ends are content coordinates — the same space [`stack_into`] stacks
513/// slots in — and the pair is what a scroll position has to be kept inside.
514///
515/// **It is not "the anchored row centred" to "the last row centred".** The
516/// `LazyColumn` underneath takes its `contentPadding` OUTSIDE both auto-centring
517/// spacers, so the padding is scroll the list can spend at each end. At the
518/// bottom that means the last row settles `after_padding` **above** the centre
519/// line rather than on it; at the top the anchored row can be pulled
520/// `before_padding` **below** it. Wear says the same thing from the other side
521/// in `ScalingLazyListState.scrollToItem`, which scrolls the `LazyColumn` to
522/// `beforeContentPaddingPx - viewportCenterLinePx` to put a row on the line —
523/// so the list is already `before_padding` in from its own top when it opens,
524/// and a port that stops at the two centred rows cannot reach either end.
525///
526/// Clamping at the two centred rows costs behaviour and not only pixels: it is
527/// the difference between a user reaching the last row of a settings list and
528/// not reaching it.
529///
530/// Both spacers are clamped at zero in Wear (see [`auto_centring_spacers`]), and
531/// this states the travel for a list where neither clamp bit — a list long
532/// enough to scroll with a leading spacer left. On one clamped at either end the
533/// true travel is shorter at that end.
534pub 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}