Skip to main content

cranpose_ui/
round_scroll_indicator.rs

1//! The curved scroll indicator a round watch puts at 3 o'clock.
2//!
3//! Every round-screen app needs this and none of it is guessable: the track is
4//! described by a height in dp rather than an angle, the thumb is a separate
5//! segment with a gap at each end rather than paint over a continuous rail, and
6//! a segment shorter than its own stroke turns into a shrinking, fading dot
7//! instead of a stubby arc.
8//!
9//! The numbers and the arithmetic here were read out of
10//! `androidx.wear.compose.material3` 1.6.2 with `javap -c` and then checked
11//! against where the shipping Compose build actually puts pixels on 454x454 and
12//! 384x384 displays. The sources are named per item so the next person can
13//! re-derive them rather than trust this comment.
14//!
15//! This module is deliberately pure geometry. It returns the segments to draw
16//! and takes no view of how they are drawn, so it costs nothing to a platform
17//! that never shows it and can be tested without a GPU.
18
19use crate::round_scaling_list::ScalingParams;
20use std::f32::consts::FRAC_PI_2;
21
22/// `ScrollIndicatorDefaults.indicatorHeight` — how far the track reaches up and
23/// down from 3 o'clock, as a straight-line height rather than an arc length.
24pub const INDICATOR_HEIGHT_DP: f32 = 50.0;
25/// `ScrollIndicatorDefaults.indicatorWidth`, whose two values are chosen by
26/// screen size.
27pub const INDICATOR_WIDTH_DP: f32 = 6.0;
28pub const INDICATOR_NARROW_WIDTH_DP: f32 = 5.0;
29/// Wear's own breakpoint: a display at least this wide gets the wider stroke.
30pub const INDICATOR_LARGE_SCREEN_DP: f32 = 225.0;
31/// `PaddingDefaults.edgePadding` — how far the track's outer edge stays off the
32/// display edge.
33pub const INDICATOR_EDGE_PADDING_DP: f32 = 2.0;
34/// `ScrollIndicatorDefaults.gapHeight` — the blank left between the thumb and
35/// each end of the track.
36pub const INDICATOR_GAP_DP: f32 = 3.0;
37/// `ScrollIndicatorDefaults.minSizeFraction` / `maxSizeFraction` — the thumb's
38/// share of the track is clamped to this range however long the list is.
39pub const INDICATOR_MIN_THUMB: f32 = 0.3;
40pub const INDICATOR_MAX_THUMB: f32 = 0.7;
41
42/// The stroke width Wear would use on a display this wide.
43pub fn indicator_width_dp(display_dp: f32) -> f32 {
44    if display_dp.is_finite() && display_dp >= INDICATOR_LARGE_SCREEN_DP {
45        INDICATOR_WIDTH_DP
46    } else {
47        INDICATOR_NARROW_WIDTH_DP
48    }
49}
50
51/// Where the track sits on a display of the given radius.
52#[derive(Clone, Copy, Debug, PartialEq)]
53pub struct IndicatorArc {
54    /// Radius of the stroke's centreline, in the same unit as the radius given.
55    centreline: f32,
56    /// Stroke width, same unit.
57    width: f32,
58    /// Half the angle the whole track covers, in radians.
59    half_sweep: f32,
60    /// Angular amount removed from every segment before round caps are drawn.
61    /// Wear derives this from the stroke width plus the visible gap.
62    segment_inset: f32,
63}
64
65impl IndicatorArc {
66    /// Radius of the stroke's centreline.
67    pub fn centreline(self) -> f32 {
68        self.centreline
69    }
70
71    /// Stroke width.
72    pub fn width(self) -> f32 {
73        self.width
74    }
75
76    /// Angular amount removed from each segment before its round caps draw.
77    pub fn segment_inset(self) -> f32 {
78        self.segment_inset
79    }
80
81    /// The angle at which the track starts, measured the way a canvas measures
82    /// it: `0` at 3 o'clock, increasing clockwise.
83    pub fn start_angle(self) -> f32 {
84        -self.half_sweep
85    }
86
87    /// The whole track's sweep in radians.
88    pub fn sweep(self) -> f32 {
89        self.half_sweep * 2.0
90    }
91
92    /// How much angle a round cap adds beyond the nominal arc at each end.
93    ///
94    /// Wear draws each segment inset by half a cap at the start and a whole cap
95    /// shorter, so the round caps put the ink back exactly on the nominal
96    /// bounds. A caller that draws with a butt cap wants this to be zero.
97    pub fn cap_sweep(self) -> f32 {
98        if self.centreline > 0.0 {
99            self.width / self.centreline
100        } else {
101            0.0
102        }
103    }
104}
105
106fn height_to_sweep(height: f32, radius: f32) -> f32 {
107    if radius <= 0.0 || !radius.is_finite() {
108        return 0.0;
109    }
110    (height * 0.5 / radius).clamp(-1.0, 1.0).asin() * 2.0
111}
112
113/// Where the track's centreline sits and how far it sweeps.
114///
115/// Wear describes the track by a height in dp, so the angle it covers depends
116/// on the radius it is drawn at — deriving it here rather than storing an angle
117/// keeps the indicator the same size in millimetres on every watch.
118///
119/// The centreline is `radius - edgePadding - strokeWidth / 2`. Wear converts
120/// both the track height and `(strokeWidth + gapHeight)` to angles using the
121/// padded radius, then adds the latter inset to the total sweep before each
122/// segment removes it again. The round caps restore the stroke-width share,
123/// leaving the requested visible gap.
124pub fn indicator_arc(radius: f32) -> IndicatorArc {
125    let width = indicator_width_dp(radius * 2.0);
126    let usable_radius = radius - INDICATOR_EDGE_PADDING_DP;
127    let centreline = usable_radius - width * 0.5;
128    if centreline <= 0.0 || !centreline.is_finite() {
129        return IndicatorArc {
130            centreline: 0.0,
131            width,
132            half_sweep: 0.0,
133            segment_inset: 0.0,
134        };
135    }
136    let segment_inset = height_to_sweep(width + INDICATOR_GAP_DP, usable_radius);
137    let half_sweep = ((height_to_sweep(INDICATOR_HEIGHT_DP, usable_radius) + segment_inset) * 0.5)
138        .min(FRAC_PI_2);
139    IndicatorArc {
140        centreline,
141        width,
142        half_sweep,
143        segment_inset,
144    }
145}
146
147/// Where the thumb sits inside the track and how long it is, both as fractions
148/// of the whole track.
149#[derive(Clone, Copy, Debug, PartialEq)]
150pub struct IndicatorGeometry {
151    /// Thumb length as a share of the track, clamped to Wear's range.
152    pub thumb: f32,
153    /// The thumb's leading edge: `0.0` at the top, `1.0 - thumb` at the bottom.
154    pub offset: f32,
155}
156
157/// Works out the thumb for a list, or `None` when everything fits on screen and
158/// Wear shows nothing at all.
159///
160/// `content` and `viewport` are lengths in any one unit; `scrolled` is how far
161/// the content has travelled, in the same unit.
162///
163/// This is the generic, flat-list model: the thumb is the share of the content
164/// on screen and it moves with the pixels. A `ScalingLazyColumn` does **not**
165/// work this way — see [`scaling_list_geometry`], which is the rule Wear's own
166/// indicator uses for one. Reach for this one when a caller genuinely scrolls
167/// pixels, and for that one when it is a Wear list.
168pub fn indicator_geometry(content: f32, viewport: f32, scrolled: f32) -> Option<IndicatorGeometry> {
169    if !(content.is_finite() && viewport.is_finite() && scrolled.is_finite()) {
170        return None;
171    }
172    if viewport <= 0.0 || content <= viewport {
173        return None;
174    }
175    let thumb = (viewport / content).clamp(INDICATOR_MIN_THUMB, INDICATOR_MAX_THUMB);
176    let travel = content - viewport;
177    let progress = (scrolled / travel).clamp(0.0, 1.0);
178    Some(IndicatorGeometry {
179        thumb,
180        offset: progress * (1.0 - thumb),
181    })
182}
183
184/// One row of a `ScalingLazyColumn`, as `ScalingLazyListItemInfo` reports it.
185///
186/// **Device pixels.** Wear's adapter reads a layout that has already been
187/// resolved onto the pixel grid — item heights are whole pixels, the viewport's
188/// centre line is an integer halving — and it divides by those integers. Doing
189/// the same arithmetic in points quietly loses the halves, and the halves are
190/// what decide which item index the thumb's ends land on.
191///
192/// [`scaling_list_items`] builds these from a laid-out list; a caller that
193/// already holds a real `ScalingLazyListLayoutInfo` can fill them in directly.
194#[derive(Clone, Copy, Debug, PartialEq)]
195pub struct IndicatorItem {
196    /// The row's index in the whole list.
197    pub index: usize,
198    /// `ScalingLazyListItemInfo.startOffset(ItemCenter)`: the row's top edge
199    /// measured from the viewport's centre line, after scaling.
200    pub start_offset: f32,
201    /// `ScalingLazyListItemInfo.size`: the row's height after scaling, rounded
202    /// to a whole pixel. Not the height the row is *drawn* at — the graphics
203    /// layer scales by the unrounded scale — but this rounded one is what the
204    /// layout info reports and therefore what the indicator divides by.
205    pub size: f32,
206}
207
208/// A scaling list as `ScalingLazyColumnStateAdapter` sees it. Device pixels.
209#[derive(Clone, Copy, Debug, PartialEq)]
210pub struct ScalingList<'a> {
211    /// The rows on screen, in order. Only the first and last are read, but the
212    /// whole window is taken because that is what the adapter is handed and
213    /// because a caller that trims it to two has to get the window right
214    /// itself.
215    pub visible: &'a [IndicatorItem],
216    /// `totalItemsCount` — every row, on screen or not. This is the
217    /// denominator the thumb's length is a share of.
218    pub total: usize,
219    /// `viewportSize.height`.
220    pub viewport: f32,
221    /// `beforeContentPadding + beforeAutoCenteringPadding`, the blank the list
222    /// keeps above its first row. It counts only while the first row is on
223    /// screen, which is the adapter's own rule and not an optimisation.
224    pub before_padding: f32,
225    /// `afterContentPadding + afterAutoCenteringPadding`, likewise below the
226    /// last row.
227    pub after_padding: f32,
228}
229
230/// Where the first visible row sits, as a fractional item index.
231///
232/// `androidx.wear.compose.material3.ScalingLazyColumnStateAdapter`. The whole
233/// part is the row's index and the fraction is how much of it has gone off the
234/// top, so a list that has scrolled half of item 3 away reads 3.5 — **an
235/// item-space position, not a pixel one**.
236pub fn decimal_first_item_index(list: ScalingList<'_>) -> f32 {
237    let Some(first) = list.visible.first() else {
238        return 0.0;
239    };
240    let offset_from_start = if first.index == 0 {
241        list.before_padding
242    } else {
243        0.0
244    };
245    let start = first.start_offset - offset_from_start;
246    let top = -(list.viewport / 2.0);
247    let fraction = ((top - start) / (first.size + offset_from_start).max(1.0)).max(0.0);
248    finite(first.index as f32 + fraction)
249}
250
251/// Where the last visible row sits, as a fractional item index.
252///
253/// The mirror of [`decimal_first_item_index`]: the fraction is how much of the
254/// row is on screen, so a list showing the top third of item 6 reads 6.33.
255pub fn decimal_last_item_index(list: ScalingList<'_>) -> f32 {
256    let Some(last) = list.visible.last() else {
257        return 0.0;
258    };
259    let span = last.size
260        + if last.index + 1 == list.total {
261            list.after_padding
262        } else {
263            0.0
264        };
265    let end = last.start_offset + span;
266    let bottom = list.viewport / 2.0;
267    let fraction = (1.0 - (end - bottom) / span.max(1.0)).min(1.0);
268    finite(last.index as f32 + fraction)
269}
270
271/// How far down the track the thumb's leading edge sits, before the thumb's own
272/// length is taken out of the travel. `0.0` at the top, `1.0` at the bottom.
273///
274/// The denominator is the number of items that are *not* on screen — how far
275/// the list can still travel, counted in items — which is why this is not the
276/// same number as a pixel scroll's progress on a list whose rows differ in
277/// height.
278pub fn position_fraction(list: ScalingList<'_>) -> f32 {
279    if list.visible.is_empty() {
280        return 0.0;
281    }
282    let first = decimal_first_item_index(list);
283    let remaining = list.total as f32 - decimal_last_item_index(list);
284    if first + remaining == 0.0 {
285        0.0
286    } else {
287        finite(first / (first + remaining))
288    }
289}
290
291/// The thumb's length, and the fact that Wear only measures it once.
292///
293/// `ScalingLazyColumnStateAdapter` holds `currentSizeFraction` and recomputes
294/// it **only when `totalItemsCount` changes**, guarded by `previousItemsCount`.
295/// That is not a cache in the sense of an optimisation, it is the behaviour:
296/// the thumb keeps the length it was given by the list's first layout and does
297/// not breathe as rows of different heights scroll past. Recomputing it every
298/// frame gives a thumb that grows and shrinks while you turn the crown, which
299/// the shipping build does not do.
300///
301/// One of these belongs to one list. Give a screen its own, and drop it (or
302/// call [`ThumbLength::forget`]) when the screen goes away, the way Wear drops
303/// the adapter with the `ScreenScaffold` that made it.
304#[derive(Clone, Copy, Debug, Default, PartialEq)]
305pub struct ThumbLength {
306    fraction: f32,
307    items: usize,
308}
309
310impl ThumbLength {
311    /// `getSizeFraction`: the share of the track the thumb covers.
312    pub fn of(&mut self, list: ScalingList<'_>) -> f32 {
313        if list.visible.is_empty() {
314            return 0.0;
315        }
316        if self.items != list.total {
317            self.items = list.total;
318            let span = decimal_last_item_index(list) - decimal_first_item_index(list);
319            let share = span / list.total.max(1) as f32;
320            self.fraction = if share.is_finite() {
321                share.clamp(INDICATOR_MIN_THUMB, INDICATOR_MAX_THUMB)
322            } else {
323                INDICATOR_MIN_THUMB
324            };
325        }
326        self.fraction
327    }
328
329    /// Forget the measured length, so the next list measures itself again.
330    pub fn forget(&mut self) {
331        *self = Self::default();
332    }
333}
334
335/// The thumb for a `ScalingLazyColumn`, in the item-index space Wear uses.
336///
337/// This is the second of the two models in this module and the one a Wear list
338/// wants. [`indicator_geometry`] answers "what share of the content is on
339/// screen, and how far have the pixels travelled"; Wear asks "what share of the
340/// *items* is on screen, and how many items are left". The two agree only when
341/// every row is the same height and the list is as tall as its content — which
342/// is why a port built on the pixel model can look right on one display size
343/// and put the thumb in the wrong place on another.
344///
345/// Returns `None` when there is nothing on screen to describe. It does not
346/// decide whether the list is scrollable at all: Wear leaves that to
347/// `ScreenScaffold`, and so does this.
348pub fn scaling_list_geometry(
349    thumb: &mut ThumbLength,
350    list: ScalingList<'_>,
351) -> Option<IndicatorGeometry> {
352    if list.visible.is_empty() || list.total == 0 || !list.viewport.is_finite() {
353        return None;
354    }
355    let size = thumb.of(list);
356    let position = position_fraction(list).clamp(0.0, 1.0);
357    Some(IndicatorGeometry {
358        thumb: size,
359        offset: position * (1.0 - size),
360    })
361}
362
363/// The rows of a laid-out scaling list that are on screen, as the adapter reads
364/// them, for a list scaled by Wear's own ramp.
365///
366/// `rows` are `(top, height)` pairs — the walk's cursor and the row's full
367/// height, the same geometry [`crate::round_scaling_list::place_row`] takes —
368/// already moved to where the list sits on screen, and in whatever unit
369/// `viewport` is given in. `density` converts that unit to device pixels;
370/// [`IndicatorItem`] is always in pixels, because that is the space Wear does
371/// this arithmetic in.
372///
373/// The window is the contiguous run of rows whose scaled rectangle still meets
374/// the viewport, which is what Wear's own walk out from the centre item
375/// produces: it stops the first time the running edge leaves the display.
376///
377/// `out` is cleared first, so one buffer can be reused frame to frame.
378pub fn scaling_list_items<I>(viewport: f32, density: f32, rows: I, out: &mut Vec<IndicatorItem>)
379where
380    I: IntoIterator<Item = (f32, f32)>,
381{
382    scaling_list_items_with(ScalingParams::WEAR, viewport, density, rows, out)
383}
384
385/// [`scaling_list_items`] for a list whose ramp is not the default one.
386///
387/// A row's reported size is its full height times the scale the ramp gave it,
388/// so a list built with different [`ScalingParams`] reports different sizes and
389/// its thumb sits somewhere else. Every list Cranpose ships uses
390/// [`ScalingParams::WEAR`] and cannot tell the two apart; a list under
391/// `LocalReduceMotion` uses [`ScalingParams::reduced_motion`], where every row
392/// reports its full height, and can.
393pub fn scaling_list_items_with<I>(
394    params: ScalingParams,
395    viewport: f32,
396    density: f32,
397    rows: I,
398    out: &mut Vec<IndicatorItem>,
399) where
400    I: IntoIterator<Item = (f32, f32)>,
401{
402    out.clear();
403    if !viewport.is_finite() || !density.is_finite() {
404        return;
405    }
406    // A caller working in continuous coordinates gets the same rule with the
407    // integer steps taken out, which is what `place_row` does with the same
408    // argument.
409    let pixels = density > 0.0;
410    let to_px = |value: f32| if pixels { value * density } else { value };
411    let round_px = |value: f32| if pixels { value.round() } else { value };
412    let viewport_px = round_px(to_px(viewport));
413    // `viewportCenterLinePx()`: half the viewport rounded DOWN, so an odd
414    // viewport gives its spare pixel to the half below the line.
415    let centre_line = if pixels {
416        (viewport_px * 0.5).floor()
417    } else {
418        viewport_px * 0.5
419    };
420    for (index, (top, height)) in rows.into_iter().enumerate() {
421        let Some(placed) =
422            crate::round_scaling_list::place_row_with(params, viewport, top, height, density)
423        else {
424            continue;
425        };
426        let height_px = round_px(to_px(height));
427        let size = round_px(height_px * placed.scale);
428        // `place_row` answers where the row is DRAWN, and Compose's drawn
429        // position carries half a pixel that the reported offset does not: the
430        // graphics layer's `translationY` is
431        // `startOffset - unadjustedStartOffset`, and each of those halves an
432        // integer height twice — once with integer division inside
433        // `convertToCenterOffset`, once in floating point inside `startOffset`
434        // — so the unadjusted row's half survives into the drawing and cancels
435        // out of the report. Undoing it here is what keeps the two coordinate
436        // systems from sitting half a pixel apart per row.
437        let drawn_top = to_px(placed.top);
438        let carried = if pixels { odd_pixel(height_px) } else { 0.0 };
439        let stacked_top = drawn_top - carried + if pixels { odd_pixel(size) } else { 0.0 };
440        if stacked_top > viewport_px || stacked_top + size < 0.0 {
441            if out.is_empty() {
442                continue;
443            }
444            break;
445        }
446        out.push(IndicatorItem {
447            index,
448            start_offset: drawn_top - carried - centre_line,
449            size,
450        });
451    }
452}
453
454/// Half a pixel when a pixel height is odd, nothing when it is even.
455fn odd_pixel(pixels: f32) -> f32 {
456    let half = pixels * 0.5;
457    half - half.floor()
458}
459
460fn finite(value: f32) -> f32 {
461    if value.is_finite() {
462        value
463    } else {
464        0.0
465    }
466}
467
468/// One piece of the indicator, ready to draw.
469///
470/// A segment shorter than its own stroke cannot be drawn as an arc without
471/// looking like a blob, so Wear swaps it for a circle that shrinks and fades
472/// out together. Callers draw whichever variant they are handed.
473#[derive(Clone, Copy, Debug, PartialEq)]
474pub enum IndicatorSegment {
475    /// A stroked arc with a round cap, already inset so the caps land on the
476    /// nominal bounds. `start` and `sweep` are radians, `0` at 3 o'clock.
477    Arc { start: f32, sweep: f32, alpha: f32 },
478    /// A filled circle standing in for an arc too short to draw.
479    Dot {
480        /// Angle of the dot's centre, radians.
481        angle: f32,
482        /// Radius, in the same unit as the arc's stroke width.
483        radius: f32,
484        alpha: f32,
485    },
486}
487
488/// Which part of the indicator a segment belongs to, so a caller can colour the
489/// thumb and the track differently without re-deriving the order.
490#[derive(Clone, Copy, Debug, PartialEq, Eq)]
491pub enum IndicatorPart {
492    Track,
493    Thumb,
494}
495
496/// The whole indicator as a list of drawable pieces: track, thumb, track.
497///
498/// It is three separate segments with a gap at each end of the thumb, not a
499/// thumb painted over a continuous rail — drawing a full-length track under a
500/// thumb gives a visibly different picture where the gaps should be.
501///
502/// `alpha` scales every piece, which is how the indicator fades out after the
503/// list has been still.
504pub fn indicator_segments(
505    arc: IndicatorArc,
506    geometry: IndicatorGeometry,
507    alpha: f32,
508) -> [(IndicatorPart, IndicatorSegment); 3] {
509    let alpha = if alpha.is_finite() {
510        alpha.clamp(0.0, 1.0)
511    } else {
512        0.0
513    };
514    let thumb = if geometry.thumb.is_finite() {
515        geometry.thumb.clamp(0.0, 1.0)
516    } else {
517        0.0
518    };
519    let offset = if geometry.offset.is_finite() {
520        geometry.offset.clamp(0.0, 1.0 - thumb)
521    } else {
522        0.0
523    };
524    let sweep = arc.sweep();
525    let top = arc.start_angle();
526    let thumb_start = top + sweep * offset;
527    let thumb_sweep = sweep * thumb;
528    let below_start = thumb_start + thumb_sweep;
529    [
530        (
531            IndicatorPart::Track,
532            segment(top, thumb_start - top, arc.width, arc.segment_inset, alpha),
533        ),
534        (
535            IndicatorPart::Thumb,
536            segment(
537                thumb_start,
538                thumb_sweep,
539                arc.width,
540                arc.segment_inset,
541                alpha,
542            ),
543        ),
544        (
545            IndicatorPart::Track,
546            segment(
547                below_start,
548                top + sweep - below_start,
549                arc.width,
550                arc.segment_inset,
551                alpha,
552            ),
553        ),
554    ]
555}
556
557/// One segment, with Wear's cap inset applied and its too-short case handled.
558fn segment(start: f32, sweep: f32, width: f32, inset: f32, alpha: f32) -> IndicatorSegment {
559    if sweep <= 0.0 || inset <= 0.0 {
560        return IndicatorSegment::Arc {
561            start,
562            sweep: 0.0,
563            alpha: 0.0,
564        };
565    }
566    if sweep < inset {
567        // Below one stroke width Wear stops drawing an arc and draws a circle
568        // that shrinks and fades on the same fraction, so a segment leaves the
569        // screen smoothly instead of collapsing into a dash.
570        let fill = sweep / inset;
571        return IndicatorSegment::Dot {
572            angle: start + sweep * 0.5,
573            radius: width * 0.5 * fill,
574            alpha: alpha * fill,
575        };
576    }
577    // `drawCurvedIndicatorSegment` starts half an inset in and runs a whole
578    // inset shorter; round caps restore the stroke share and leave the gap.
579    IndicatorSegment::Arc {
580        start: start + inset * 0.5,
581        sweep: sweep - inset,
582        alpha,
583    }
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589
590    /// The two displays Google Play requires a Wear app to support, in dp.
591    const LARGE_RADIUS_DP: f32 = 113.5; // 454px at density 2
592    const SMALL_RADIUS_DP: f32 = 96.0; // 384px at density 2
593
594    #[test]
595    fn stroke_width_switches_at_the_wear_large_screen_breakpoint() {
596        assert_eq!(indicator_width_dp(224.99), INDICATOR_NARROW_WIDTH_DP);
597        assert_eq!(indicator_width_dp(225.0), INDICATOR_WIDTH_DP);
598        assert_eq!(indicator_width_dp(f32::NAN), INDICATOR_NARROW_WIDTH_DP);
599    }
600
601    #[test]
602    fn the_track_lands_where_the_shipping_compose_build_draws_it() {
603        // Measured off the Compose build itself: the stroke's centreline sits
604        // at 108.5dp on a 454px display and 91.5dp on a 384px one, and the
605        // stroke is 6dp on the first and 5dp on the second.
606        let large = indicator_arc(LARGE_RADIUS_DP);
607        assert!((large.centreline() - 108.5).abs() < 0.01, "{large:?}");
608        assert!((large.width() - 6.0).abs() < 0.01, "{large:?}");
609
610        let small = indicator_arc(SMALL_RADIUS_DP);
611        assert!((small.centreline() - 91.5).abs() < 0.01, "{small:?}");
612        assert!((small.width() - 5.0).abs() < 0.01, "{small:?}");
613    }
614
615    #[test]
616    fn the_sweep_is_a_height_in_dp_not_a_fixed_angle() {
617        // The same 50dp track covers a wider angle on a smaller watch, which is
618        // the whole point of storing a height rather than an angle.
619        let large = indicator_arc(LARGE_RADIUS_DP).sweep().to_degrees();
620        let small = indicator_arc(SMALL_RADIUS_DP).sweep().to_degrees();
621        assert!((large - 30.54).abs() < 0.05, "{large}");
622        assert!((small - 35.73).abs() < 0.05, "{small}");
623        assert!(small > large);
624    }
625
626    #[test]
627    fn a_list_that_fits_on_screen_shows_no_indicator_at_all() {
628        assert_eq!(indicator_geometry(100.0, 100.0, 0.0), None);
629        assert_eq!(indicator_geometry(80.0, 100.0, 0.0), None);
630        assert_eq!(indicator_geometry(f32::NAN, 100.0, 0.0), None);
631        assert_eq!(indicator_geometry(200.0, 0.0, 0.0), None);
632    }
633
634    #[test]
635    fn the_thumb_is_the_viewport_share_clamped_at_both_ends() {
636        // Half the content visible is half the track...
637        let half = indicator_geometry(200.0, 100.0, 0.0).unwrap();
638        assert!((half.thumb - 0.5).abs() < 1e-6, "{half:?}");
639        // ...but a very long list never shrinks it past the floor, and a barely
640        // scrolling one never grows it past the ceiling.
641        let long = indicator_geometry(10_000.0, 100.0, 0.0).unwrap();
642        assert!((long.thumb - INDICATOR_MIN_THUMB).abs() < 1e-6, "{long:?}");
643        let short = indicator_geometry(105.0, 100.0, 0.0).unwrap();
644        assert!(
645            (short.thumb - INDICATOR_MAX_THUMB).abs() < 1e-6,
646            "{short:?}"
647        );
648    }
649
650    #[test]
651    fn the_thumb_reaches_the_bottom_of_the_track_and_no_further() {
652        let bottom = indicator_geometry(200.0, 100.0, 100.0).unwrap();
653        assert!(
654            (bottom.offset + bottom.thumb - 1.0).abs() < 1e-6,
655            "{bottom:?}"
656        );
657        // Overscrolling past the end must not push it off the track.
658        let past = indicator_geometry(200.0, 100.0, 500.0).unwrap();
659        assert_eq!(past, bottom);
660    }
661
662    #[test]
663    fn the_indicator_is_three_segments_with_a_gap_either_side_of_the_thumb() {
664        let arc = indicator_arc(LARGE_RADIUS_DP);
665        let geometry = IndicatorGeometry {
666            thumb: 0.4,
667            offset: 0.3,
668        };
669        let parts = indicator_segments(arc, geometry, 1.0);
670        assert_eq!(parts[0].0, IndicatorPart::Track);
671        assert_eq!(parts[1].0, IndicatorPart::Thumb);
672        assert_eq!(parts[2].0, IndicatorPart::Track);
673
674        // Every piece is an arc at this size, and the ink they cover — the
675        // nominal bounds, once the round caps undo the inset — must stay inside
676        // the track with the gaps left blank.
677        let ink_bounds = |segment: IndicatorSegment| match segment {
678            IndicatorSegment::Arc { start, sweep, .. } => {
679                (start - arc.cap_sweep() * 0.5, sweep + arc.cap_sweep())
680            }
681            other => panic!("expected an arc, got {other:?}"),
682        };
683        let (above_start, above_sweep) = ink_bounds(parts[0].1);
684        let (thumb_start, thumb_sweep) = ink_bounds(parts[1].1);
685        let (below_start, below_sweep) = ink_bounds(parts[2].1);
686        let gap = arc.segment_inset() - arc.cap_sweep();
687
688        assert!((above_start - arc.start_angle() - gap * 0.5).abs() < 1e-4);
689        assert!((thumb_start - (above_start + above_sweep) - gap).abs() < 1e-4);
690        assert!((below_start - (thumb_start + thumb_sweep) - gap).abs() < 1e-4);
691        assert!(
692            (below_start + below_sweep + gap * 0.5 - (arc.start_angle() + arc.sweep())).abs()
693                < 1e-4,
694            "the track has to end where it should"
695        );
696    }
697
698    #[test]
699    fn a_segment_shorter_than_its_stroke_becomes_a_shrinking_dot() {
700        let arc = indicator_arc(LARGE_RADIUS_DP);
701        // Thumb hard against the top: the track above it has almost no room.
702        let parts = indicator_segments(
703            arc,
704            IndicatorGeometry {
705                thumb: 0.7,
706                offset: 0.0,
707            },
708            1.0,
709        );
710        match parts[0].1 {
711            IndicatorSegment::Dot { radius, alpha, .. } => {
712                assert!(
713                    radius <= arc.width() * 0.5,
714                    "a dot never exceeds the stroke"
715                );
716                assert!(alpha < 1.0, "it fades on the same fraction as it shrinks");
717            }
718            IndicatorSegment::Arc { sweep, .. } => {
719                assert!(sweep <= 0.0, "an arc this short should have been a dot");
720            }
721        }
722    }
723
724    #[test]
725    fn fading_the_indicator_fades_every_piece_of_it() {
726        let arc = indicator_arc(LARGE_RADIUS_DP);
727        let geometry = IndicatorGeometry {
728            thumb: 0.4,
729            offset: 0.3,
730        };
731        for (_, segment) in indicator_segments(arc, geometry, 0.25) {
732            let alpha = match segment {
733                IndicatorSegment::Arc { alpha, .. } => alpha,
734                IndicatorSegment::Dot { alpha, .. } => alpha,
735            };
736            assert!(alpha <= 0.25 + 1e-6, "{segment:?}");
737        }
738    }
739
740    #[test]
741    fn a_display_too_small_to_hold_the_track_degrades_instead_of_panicking() {
742        let tiny = indicator_arc(1.0);
743        assert_eq!(tiny.centreline(), 0.0);
744        assert_eq!(tiny.sweep(), 0.0);
745        assert_eq!(tiny.cap_sweep(), 0.0);
746        // And asking for its segments must not divide by that zero.
747        let parts = indicator_segments(
748            tiny,
749            IndicatorGeometry {
750                thumb: 0.4,
751                offset: 0.3,
752            },
753            1.0,
754        );
755        for (_, segment) in parts {
756            assert!(matches!(segment, IndicatorSegment::Arc { sweep: 0.0, .. }));
757        }
758    }
759
760    /// A synthetic list to hang the adapter's arithmetic on: a 400px viewport,
761    /// so the centre line is 200 and `startOffset` is a row's top edge measured
762    /// from there, and ten rows of 100px.
763    const VIEWPORT: f32 = 400.0;
764
765    fn list<'a>(visible: &'a [IndicatorItem]) -> ScalingList<'a> {
766        ScalingList {
767            visible,
768            total: 10,
769            viewport: VIEWPORT,
770            before_padding: 0.0,
771            after_padding: 0.0,
772        }
773    }
774
775    fn row(index: usize, start_offset: f32) -> IndicatorItem {
776        IndicatorItem {
777            index,
778            start_offset,
779            size: 100.0,
780        }
781    }
782
783    #[test]
784    fn a_row_flush_with_the_top_of_the_screen_is_a_whole_index() {
785        // Its top edge is 200px above the centre line, which is the top of the
786        // display, so none of it has scrolled away.
787        let rows = [row(3, -200.0), row(6, 100.0)];
788        assert_eq!(decimal_first_item_index(list(&rows)), 3.0);
789    }
790
791    #[test]
792    fn a_row_half_off_the_top_reads_half_an_index() {
793        // 250 above the centre line is 50 above the display, half of a 100px
794        // row -- and the answer is in ITEM units, which is the whole point of
795        // this model: 3.5 means "three and a half items have gone past".
796        let rows = [row(3, -250.0), row(6, 100.0)];
797        assert_eq!(decimal_first_item_index(list(&rows)), 3.5);
798    }
799
800    #[test]
801    fn the_last_index_counts_how_much_of_the_row_is_on_screen() {
802        // Bottom half of the display is 0..200 below the centre line; a row
803        // starting at 150 has 50 of its 100 showing.
804        let rows = [row(3, -200.0), row(6, 150.0)];
805        assert_eq!(decimal_last_item_index(list(&rows)), 6.5);
806    }
807
808    #[test]
809    fn the_padding_outside_the_list_counts_only_at_the_end_it_belongs_to() {
810        let rows = [row(0, -250.0), row(9, 150.0)];
811        let padded = ScalingList {
812            before_padding: 80.0,
813            after_padding: 60.0,
814            ..list(&rows)
815        };
816        // The first row's own span grows by the blank above it, and so does the
817        // distance it has travelled: (200 - 250 + 80) / (100 + 80).
818        assert!((decimal_first_item_index(padded) - 130.0 / 180.0).abs() < 1e-6);
819        // The last row's span grows by the blank below it: 1 - (310 - 200)/160.
820        assert!((decimal_last_item_index(padded) - (9.0 + 0.3125)).abs() < 1e-6);
821
822        // The same geometry in the middle of the list ignores both.
823        let inner = [row(3, -250.0), row(6, 150.0)];
824        let inner = ScalingList {
825            before_padding: 80.0,
826            after_padding: 60.0,
827            ..list(&inner)
828        };
829        assert_eq!(decimal_first_item_index(inner), 3.5);
830        assert_eq!(decimal_last_item_index(inner), 6.5);
831    }
832
833    #[test]
834    fn the_thumb_is_the_share_of_the_items_on_screen_not_of_the_pixels() {
835        // Five of ten items on screen is half the track, whatever those items
836        // are worth in pixels.
837        let rows = [row(3, -250.0), row(8, 150.0)];
838        let mut thumb = ThumbLength::default();
839        assert!((thumb.of(list(&rows)) - 0.5).abs() < 1e-6);
840    }
841
842    #[test]
843    fn the_thumb_is_clamped_at_both_ends_however_long_the_list_is() {
844        let rows = [row(3, -250.0), row(4, 150.0)];
845        let mut short = ThumbLength::default();
846        assert_eq!(short.of(list(&rows)), INDICATOR_MIN_THUMB);
847
848        let rows = [row(0, -250.0), row(9, 150.0)];
849        let mut long = ThumbLength::default();
850        assert_eq!(long.of(list(&rows)), INDICATOR_MAX_THUMB);
851    }
852
853    #[test]
854    fn the_thumb_is_measured_once_and_then_only_when_the_list_changes_length() {
855        // `previousItemsCount` in the adapter. Not an optimisation: a thumb
856        // remeasured every frame breathes as rows of different heights scroll
857        // past, and the shipping build's does not move at all.
858        let mut thumb = ThumbLength::default();
859        let five = [row(3, -250.0), row(8, 150.0)];
860        assert!((thumb.of(list(&five)) - 0.5).abs() < 1e-6);
861
862        let three = [row(3, -250.0), row(6, 150.0)];
863        assert!(
864            (thumb.of(list(&three)) - 0.5).abs() < 1e-6,
865            "the window shrank but the list did not, so the thumb holds"
866        );
867
868        let longer = ScalingList {
869            total: 20,
870            ..list(&three)
871        };
872        assert_eq!(thumb.of(longer), INDICATOR_MIN_THUMB);
873
874        thumb.forget();
875        assert!((thumb.of(list(&five)) - 0.5).abs() < 1e-6);
876    }
877
878    #[test]
879    fn the_position_is_how_many_items_are_left_not_how_far_the_pixels_went() {
880        // Three and a half items above the window, three and a half below it.
881        let rows = [row(3, -250.0), row(6, 150.0)];
882        assert!((position_fraction(list(&rows)) - 0.5).abs() < 1e-6);
883    }
884
885    #[test]
886    fn a_list_at_the_top_puts_the_thumb_at_the_top_and_one_at_the_end_at_the_end() {
887        let mut thumb = ThumbLength::default();
888        let top = [row(0, -200.0), row(3, 150.0)];
889        let geometry = scaling_list_geometry(&mut thumb, list(&top)).unwrap();
890        assert_eq!(geometry.offset, 0.0);
891
892        // The last row measured fully in leaves nothing after it, so the thumb
893        // is flush with the end of the track.
894        let mut thumb = ThumbLength::default();
895        let end = [row(6, -250.0), row(9, 100.0)];
896        let geometry = scaling_list_geometry(&mut thumb, list(&end)).unwrap();
897        assert_eq!(decimal_last_item_index(list(&end)), 10.0);
898        assert!((geometry.offset + geometry.thumb - 1.0).abs() < 1e-6);
899    }
900
901    #[test]
902    fn a_list_with_nothing_on_screen_has_no_indicator() {
903        let mut thumb = ThumbLength::default();
904        assert_eq!(scaling_list_geometry(&mut thumb, list(&[])), None);
905        let rows = [row(3, -250.0)];
906        let empty = ScalingList {
907            total: 0,
908            ..list(&rows)
909        };
910        assert_eq!(scaling_list_geometry(&mut thumb, empty), None);
911    }
912
913    #[test]
914    fn the_two_models_disagree_the_moment_the_rows_are_not_all_the_same_height() {
915        // This is the whole reason the second entry point exists. One tall row
916        // and nine short ones: the pixel model says the thumb is the share of
917        // the CONTENT on screen, the adapter says it is the share of the ITEMS,
918        // and the tall row counts for one item and for six rows' worth of
919        // pixels. A caller that reaches for the flat model on a Wear list gets
920        // an answer that happens to look right on a list of uniform rows.
921        let heights: Vec<f32> = std::iter::once(600.0).chain([100.0; 9]).collect();
922        let content: f32 = heights.iter().sum();
923        let pixel = indicator_geometry(content, VIEWPORT, 0.0).unwrap();
924
925        let rows = [row(0, -200.0), row(3, 100.0)];
926        let mut thumb = ThumbLength::default();
927        let wear = scaling_list_geometry(&mut thumb, list(&rows)).unwrap();
928
929        assert!((pixel.thumb - INDICATOR_MIN_THUMB).abs() < 1e-6);
930        assert!((wear.thumb - 0.4).abs() < 1e-6, "{wear:?}");
931    }
932
933    #[test]
934    fn a_reported_row_is_not_the_row_as_it_is_drawn() {
935        // `place_row` answers where a row is DRAWN and the adapter reads what
936        // the layout REPORTS, and Compose's two halvings of an odd pixel height
937        // put half a pixel between them. 103px is odd; 104px is not.
938        let mut out = Vec::new();
939        let density = 2.0;
940        let viewport = 227.0;
941        for (height, carried) in [(51.5, 0.5), (52.0, 0.0)] {
942            scaling_list_items(viewport, density, [(20.0, height)], &mut out);
943            let drawn = crate::round_scaling_list::place_row(viewport, 20.0, height, density)
944                .expect("placed");
945            let item = out.first().expect("on screen");
946            assert!(
947                (item.start_offset - (drawn.top * density - carried - 227.0)).abs() < 1e-4,
948                "{height}dp: reported {} against drawn {}",
949                item.start_offset,
950                drawn.top * density
951            );
952            // And the size it reports is the drawn height rounded to a pixel,
953            // which is not the height the graphics layer scales to.
954            assert_eq!(item.size, (drawn.height * density).round());
955        }
956    }
957
958    #[test]
959    fn a_list_that_does_not_scale_its_rows_reports_them_at_full_height() {
960        // `scaling_list_items` baked in `ScalingParams::WEAR`, so a list under
961        // `LocalReduceMotion` — where the ramp is off and every row keeps its
962        // size — was described to the indicator as though its edge rows had
963        // shrunk. Identical for every list Cranpose ships and wrong for that
964        // one, which is the shape of defect a `_with` variant exists to stop.
965        let mut wear = Vec::new();
966        let mut still = Vec::new();
967        let rows = [(4.0, 52.0), (60.0, 52.0), (116.0, 52.0)];
968        scaling_list_items(227.0, 2.0, rows, &mut wear);
969        scaling_list_items_with(
970            ScalingParams::WEAR.reduced_motion(),
971            227.0,
972            2.0,
973            rows,
974            &mut still,
975        );
976        assert_eq!(wear.len(), still.len());
977        assert!(
978            wear[0].size < still[0].size,
979            "the top row shrinks under the Wear ramp and not under a stilled \
980             one: {} vs {}",
981            wear[0].size,
982            still[0].size
983        );
984        assert_eq!(still[0].size, 104.0, "52dp at density 2, unscaled");
985        // And the default entry point is still the Wear ramp.
986        let mut default = Vec::new();
987        scaling_list_items_with(ScalingParams::WEAR, 227.0, 2.0, rows, &mut default);
988        assert_eq!(default, wear);
989    }
990
991    #[test]
992    fn the_window_is_the_rows_that_still_meet_the_display() {
993        // Ten 40dp rows down a 227dp screen, the list scrolled so row 0 starts
994        // 100dp above the top. Wear walks out from the centre item and stops at
995        // the first row whose edge has left the viewport, which is the same
996        // contiguous run.
997        let mut out = Vec::new();
998        let rows: Vec<(f32, f32)> = (0..10)
999            .map(|index| (index as f32 * 40.0 - 100.0, 40.0))
1000            .collect();
1001        scaling_list_items(227.0, 2.0, rows.iter().copied(), &mut out);
1002        let indices: Vec<usize> = out.iter().map(|item| item.index).collect();
1003        assert_eq!(indices, vec![2, 3, 4, 5, 6, 7, 8]);
1004    }
1005
1006    #[test]
1007    fn invalid_scaling_list_input_never_produces_a_non_finite_thumb() {
1008        let mut out = Vec::new();
1009        scaling_list_items(f32::NAN, 2.0, [(0.0, 40.0)], &mut out);
1010        assert!(out.is_empty());
1011        scaling_list_items(227.0, f32::NAN, [(0.0, 40.0)], &mut out);
1012        assert!(out.is_empty());
1013
1014        let rows = [
1015            IndicatorItem {
1016                index: 0,
1017                start_offset: f32::NAN,
1018                size: 0.0,
1019            },
1020            IndicatorItem {
1021                index: 3,
1022                start_offset: f32::INFINITY,
1023                size: -1.0,
1024            },
1025        ];
1026        let mut thumb = ThumbLength::default();
1027        let geometry = scaling_list_geometry(&mut thumb, list(&rows)).expect("a geometry");
1028        assert!(
1029            geometry.thumb.is_finite() && geometry.offset.is_finite(),
1030            "{geometry:?}"
1031        );
1032        assert!(geometry.thumb >= INDICATOR_MIN_THUMB && geometry.thumb <= INDICATOR_MAX_THUMB);
1033        assert!(geometry.offset >= 0.0 && geometry.offset <= 1.0);
1034    }
1035
1036    #[test]
1037    fn invalid_public_inputs_never_emit_non_finite_draw_values() {
1038        for radius in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -1.0] {
1039            let arc = indicator_arc(radius);
1040            assert_eq!(arc.sweep(), 0.0);
1041            assert_eq!(arc.segment_inset(), 0.0);
1042        }
1043
1044        let parts = indicator_segments(
1045            indicator_arc(LARGE_RADIUS_DP),
1046            IndicatorGeometry {
1047                thumb: f32::NAN,
1048                offset: f32::INFINITY,
1049            },
1050            f32::NAN,
1051        );
1052        for (_, part) in parts {
1053            match part {
1054                IndicatorSegment::Arc {
1055                    start,
1056                    sweep,
1057                    alpha,
1058                } => assert!(start.is_finite() && sweep.is_finite() && alpha == 0.0),
1059                IndicatorSegment::Dot {
1060                    angle,
1061                    radius,
1062                    alpha,
1063                } => assert!(angle.is_finite() && radius.is_finite() && alpha == 0.0),
1064            }
1065        }
1066    }
1067}