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