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;
32/// The narrower stroke width, used on displays narrower than [`INDICATOR_LARGE_SCREEN_DP`].
33pub const INDICATOR_NARROW_WIDTH_DP: f32 = 5.0;
34/// Wear's own breakpoint: a display at least this wide gets the wider stroke.
35pub const INDICATOR_LARGE_SCREEN_DP: f32 = 225.0;
36/// `PaddingDefaults.edgePadding` — how far the track's outer edge stays off the
37/// display edge.
38pub const INDICATOR_EDGE_PADDING_DP: f32 = 2.0;
39/// `ScrollIndicatorDefaults.gapHeight` — the blank left between the thumb and
40/// each end of the track.
41pub const INDICATOR_GAP_DP: f32 = 3.0;
42/// `ScrollIndicatorDefaults.minSizeFraction` / `maxSizeFraction` — the thumb's
43/// share of the track is clamped to this range however long the list is.
44pub const INDICATOR_MIN_THUMB: f32 = 0.3;
45/// Largest share of the track the thumb fills.
46pub const INDICATOR_MAX_THUMB: f32 = 0.7;
47
48/// The stroke width Wear would use on a display this wide.
49pub fn indicator_width_dp(display_dp: f32) -> f32 {
50    if display_dp.is_finite() && display_dp >= INDICATOR_LARGE_SCREEN_DP {
51        INDICATOR_WIDTH_DP
52    } else {
53        INDICATOR_NARROW_WIDTH_DP
54    }
55}
56
57/// Where the track sits on a display of the given radius.
58#[derive(Clone, Copy, Debug, PartialEq)]
59pub struct IndicatorArc {
60    centreline: f32,
61    width: f32,
62    half_sweep: f32,
63    segment_inset: f32,
64}
65
66impl IndicatorArc {
67    /// Radius of the stroke's centreline.
68    pub fn centreline(self) -> f32 {
69        self.centreline
70    }
71
72    /// Stroke width.
73    pub fn width(self) -> f32 {
74        self.width
75    }
76
77    /// Angular amount removed from each segment before its round caps draw.
78    pub fn segment_inset(self) -> f32 {
79        self.segment_inset
80    }
81
82    /// The angle at which the track starts, measured the way a canvas measures
83    /// it: `0` at 3 o'clock, increasing clockwise.
84    pub fn start_angle(self) -> f32 {
85        -self.half_sweep
86    }
87
88    /// The whole track's sweep in radians.
89    pub fn sweep(self) -> f32 {
90        self.half_sweep * 2.0
91    }
92
93    /// How much angle a round cap adds beyond the nominal arc at each end.
94    ///
95    /// Wear draws each segment inset by half a cap at the start and a whole cap
96    /// shorter, so the round caps put the ink back exactly on the nominal
97    /// bounds. A caller that draws with a butt cap wants this to be zero.
98    pub fn cap_sweep(self) -> f32 {
99        if self.centreline > 0.0 {
100            self.width / self.centreline
101        } else {
102            0.0
103        }
104    }
105}
106
107fn height_to_sweep(height: f32, radius: f32) -> f32 {
108    if radius <= 0.0 || !radius.is_finite() {
109        return 0.0;
110    }
111    (height * 0.5 / radius).clamp(-1.0, 1.0).asin() * 2.0
112}
113
114/// Where the track's centreline sits and how far it sweeps.
115///
116/// Wear describes the track by a height in dp, so the angle it covers depends
117/// on the radius it is drawn at — deriving it here rather than storing an angle
118/// keeps the indicator the same size in millimetres on every watch.
119///
120/// The centreline is `radius - edgePadding - strokeWidth / 2`. Wear converts
121/// both the track height and `(strokeWidth + gapHeight)` to angles using the
122/// padded radius, then adds the latter inset to the total sweep before each
123/// segment removes it again. The round caps restore the stroke-width share,
124/// leaving the requested visible gap.
125pub fn indicator_arc(radius: f32) -> IndicatorArc {
126    let width = indicator_width_dp(radius * 2.0);
127    let usable_radius = radius - INDICATOR_EDGE_PADDING_DP;
128    let centreline = usable_radius - width * 0.5;
129    if centreline <= 0.0 || !centreline.is_finite() {
130        return IndicatorArc {
131            centreline: 0.0,
132            width,
133            half_sweep: 0.0,
134            segment_inset: 0.0,
135        };
136    }
137    let segment_inset = height_to_sweep(width + INDICATOR_GAP_DP, usable_radius);
138    let half_sweep = ((height_to_sweep(INDICATOR_HEIGHT_DP, usable_radius) + segment_inset) * 0.5)
139        .min(FRAC_PI_2);
140    IndicatorArc {
141        centreline,
142        width,
143        half_sweep,
144        segment_inset,
145    }
146}
147
148/// Where the thumb sits inside the track and how long it is, both as fractions
149/// of the whole track.
150#[derive(Clone, Copy, Debug, PartialEq)]
151pub struct IndicatorGeometry {
152    /// Thumb length as a share of the track, clamped to Wear's range.
153    pub thumb: f32,
154    /// The thumb's leading edge: `0.0` at the top, `1.0 - thumb` at the bottom.
155    pub offset: f32,
156}
157
158/// Works out the thumb for a list, or `None` when everything fits on screen and
159/// Wear shows nothing at all.
160///
161/// `content` and `viewport` are lengths in any one unit; `scrolled` is how far
162/// the content has travelled, in the same unit.
163///
164/// This is the generic, flat-list model: the thumb is the share of the content
165/// on screen and it moves with the pixels. A `ScalingLazyColumn` does **not**
166/// work this way — see [`scaling_list_geometry`], which is the rule Wear's own
167/// indicator uses for one. Reach for this one when a caller genuinely scrolls
168/// pixels, and for that one when it is a Wear list.
169pub fn indicator_geometry(content: f32, viewport: f32, scrolled: f32) -> Option<IndicatorGeometry> {
170    thumb_geometry(
171        content,
172        viewport,
173        scrolled,
174        ThumbBounds::new(INDICATOR_MIN_THUMB, INDICATOR_MAX_THUMB),
175    )
176    .map(|geometry| IndicatorGeometry {
177        thumb: geometry.length,
178        offset: geometry.offset,
179    })
180}
181
182/// One row of a `ScalingLazyColumn`, as `ScalingLazyListItemInfo` reports it.
183///
184/// **Device pixels.** Wear's adapter reads a layout that has already been
185/// resolved onto the pixel grid — item heights are whole pixels, the viewport's
186/// centre line is an integer halving — and it divides by those integers. Doing
187/// the same arithmetic in points quietly loses the halves, and the halves are
188/// what decide which item index the thumb's ends land on.
189///
190/// [`scaling_list_items`] builds these from a laid-out list; a caller that
191/// already holds a real `ScalingLazyListLayoutInfo` can fill them in directly.
192#[derive(Clone, Copy, Debug, PartialEq)]
193pub struct IndicatorItem {
194    /// The row's index in the whole list.
195    pub index: usize,
196    /// `ScalingLazyListItemInfo.startOffset(ItemCenter)`: the row's top edge
197    /// measured from the viewport's centre line, after scaling.
198    pub start_offset: f32,
199    /// `ScalingLazyListItemInfo.size`: the row's height after scaling, rounded
200    /// to a whole pixel. Not the height the row is *drawn* at — the graphics
201    /// layer scales by the unrounded scale — but this rounded one is what the
202    /// layout info reports and therefore what the indicator divides by.
203    pub size: f32,
204}
205
206/// A scaling list as `ScalingLazyColumnStateAdapter` sees it. Device pixels.
207#[derive(Clone, Copy, Debug, PartialEq)]
208pub struct ScalingList<'a> {
209    /// The rows on screen, in order. Only the first and last are read, but the
210    /// whole window is taken because that is what the adapter is handed and
211    /// because a caller that trims it to two has to get the window right
212    /// itself.
213    pub visible: &'a [IndicatorItem],
214    /// `totalItemsCount` — every row, on screen or not. This is the
215    /// denominator the thumb's length is a share of.
216    pub total: usize,
217    /// `viewportSize.height`.
218    pub viewport: f32,
219    /// `beforeContentPadding + beforeAutoCenteringPadding`, the blank the list
220    /// keeps above its first row. It counts only while the first row is on
221    /// screen, which is the adapter's own rule and not an optimisation.
222    pub before_padding: f32,
223    /// `afterContentPadding + afterAutoCenteringPadding`, likewise below the
224    /// last row.
225    pub after_padding: f32,
226}
227
228/// Where the first visible row sits, as a fractional item index.
229///
230/// `androidx.wear.compose.material3.ScalingLazyColumnStateAdapter`. The whole
231/// part is the row's index and the fraction is how much of it has gone off the
232/// top, so a list that has scrolled half of item 3 away reads 3.5 — **an
233/// item-space position, not a pixel one**.
234pub fn decimal_first_item_index(list: ScalingList<'_>) -> f32 {
235    let Some(first) = list.visible.first() else {
236        return 0.0;
237    };
238    let offset_from_start = if first.index == 0 {
239        list.before_padding
240    } else {
241        0.0
242    };
243    let start = first.start_offset - offset_from_start;
244    let top = -(list.viewport / 2.0);
245    let fraction = ((top - start) / (first.size + offset_from_start).max(1.0)).max(0.0);
246    finite(first.index as f32 + fraction)
247}
248
249/// Where the last visible row sits, as a fractional item index.
250///
251/// The mirror of [`decimal_first_item_index`]: the fraction is how much of the
252/// row is on screen, so a list showing the top third of item 6 reads 6.33.
253pub fn decimal_last_item_index(list: ScalingList<'_>) -> f32 {
254    let Some(last) = list.visible.last() else {
255        return 0.0;
256    };
257    let span = last.size
258        + if last.index + 1 == list.total {
259            list.after_padding
260        } else {
261            0.0
262        };
263    let end = last.start_offset + span;
264    let bottom = list.viewport / 2.0;
265    let fraction = (1.0 - (end - bottom) / span.max(1.0)).min(1.0);
266    finite(last.index as f32 + fraction)
267}
268
269/// How far down the track the thumb's leading edge sits, before the thumb's own
270/// length is taken out of the travel. `0.0` at the top, `1.0` at the bottom.
271///
272/// The denominator is the number of items that are *not* on screen — how far
273/// the list can still travel, counted in items — which is why this is not the
274/// same number as a pixel scroll's progress on a list whose rows differ in
275/// height.
276pub fn position_fraction(list: ScalingList<'_>) -> f32 {
277    if list.visible.is_empty() {
278        return 0.0;
279    }
280    let first = decimal_first_item_index(list);
281    let remaining = list.total as f32 - decimal_last_item_index(list);
282    if first + remaining == 0.0 {
283        0.0
284    } else {
285        finite(first / (first + remaining))
286    }
287}
288
289/// The thumb's length, and the fact that Wear only measures it once.
290///
291/// `ScalingLazyColumnStateAdapter` holds `currentSizeFraction` and recomputes
292/// it **only when `totalItemsCount` changes**, guarded by `previousItemsCount`.
293/// That is not a cache in the sense of an optimisation, it is the behaviour:
294/// the thumb keeps the length it was given by the list's first layout and does
295/// not breathe as rows of different heights scroll past. Recomputing it every
296/// frame gives a thumb that grows and shrinks while you turn the crown, which
297/// the shipping build does not do.
298///
299/// One of these belongs to one list. Give a screen its own, and drop it (or
300/// call [`ThumbLength::forget`]) when the screen goes away, the way Wear drops
301/// the adapter with the `ScreenScaffold` that made it.
302#[derive(Clone, Copy, Debug, Default, PartialEq)]
303pub struct ThumbLength {
304    fraction: f32,
305    items: usize,
306}
307
308impl ThumbLength {
309    /// `getSizeFraction`: the share of the track the thumb covers.
310    pub fn of(&mut self, list: ScalingList<'_>) -> f32 {
311        if list.visible.is_empty() {
312            return 0.0;
313        }
314        if self.items != list.total {
315            self.items = list.total;
316            let span = decimal_last_item_index(list) - decimal_first_item_index(list);
317            let share = span / list.total.max(1) as f32;
318            self.fraction = if share.is_finite() {
319                share.clamp(INDICATOR_MIN_THUMB, INDICATOR_MAX_THUMB)
320            } else {
321                INDICATOR_MIN_THUMB
322            };
323        }
324        self.fraction
325    }
326
327    /// Forget the measured length, so the next list measures itself again.
328    pub fn forget(&mut self) {
329        *self = Self::default();
330    }
331}
332
333/// The thumb for a `ScalingLazyColumn`, in the item-index space Wear uses.
334///
335/// This is the second of the two models in this module and the one a Wear list
336/// wants. [`indicator_geometry`] answers "what share of the content is on
337/// screen, and how far have the pixels travelled"; Wear asks "what share of the
338/// *items* is on screen, and how many items are left". The two agree only when
339/// every row is the same height and the list is as tall as its content — which
340/// is why a port built on the pixel model can look right on one display size
341/// and put the thumb in the wrong place on another.
342///
343/// Returns `None` when there is nothing on screen to describe. It does not
344/// decide whether the list is scrollable at all: Wear leaves that to
345/// `ScreenScaffold`, and so does this.
346pub fn scaling_list_geometry(
347    thumb: &mut ThumbLength,
348    list: ScalingList<'_>,
349) -> Option<IndicatorGeometry> {
350    if list.visible.is_empty() || list.total == 0 || !list.viewport.is_finite() {
351        return None;
352    }
353    let size = thumb.of(list);
354    let position = position_fraction(list).clamp(0.0, 1.0);
355    Some(IndicatorGeometry {
356        thumb: size,
357        offset: position * (1.0 - size),
358    })
359}
360
361/// The rows of a laid-out scaling list that are on screen, as the adapter reads
362/// them, for a list scaled by Wear's own ramp.
363///
364/// `rows` are `(top, height)` pairs — the walk's cursor and the row's full
365/// height, the same geometry [`crate::round_scaling_list::place_row`] takes —
366/// already moved to where the list sits on screen, and in whatever unit
367/// `viewport` is given in. `density` converts that unit to device pixels;
368/// [`IndicatorItem`] is always in pixels, because that is the space Wear does
369/// this arithmetic in.
370///
371/// The window is the contiguous run of rows whose scaled rectangle still meets
372/// the viewport, which is what Wear's own walk out from the centre item
373/// produces: it stops the first time the running edge leaves the display.
374///
375/// `out` is cleared first, so one buffer can be reused frame to frame.
376pub fn scaling_list_items<I>(viewport: f32, density: f32, rows: I, out: &mut Vec<IndicatorItem>)
377where
378    I: IntoIterator<Item = (f32, f32)>,
379{
380    scaling_list_items_with(ScalingParams::WEAR, viewport, density, rows, out);
381}
382
383/// [`scaling_list_items`] for a list whose ramp is not the default one.
384///
385/// A row's reported size is its full height times the scale the ramp gave it,
386/// so a list built with different [`ScalingParams`] reports different sizes and
387/// its thumb sits somewhere else. Every list Cranpose ships uses
388/// [`ScalingParams::WEAR`] and cannot tell the two apart; a list under
389/// `LocalReduceMotion` uses [`ScalingParams::reduced_motion`], where every row
390/// reports its full height, and can.
391pub fn scaling_list_items_with<I>(
392    params: ScalingParams,
393    viewport: f32,
394    density: f32,
395    rows: I,
396    out: &mut Vec<IndicatorItem>,
397) where
398    I: IntoIterator<Item = (f32, f32)>,
399{
400    out.clear();
401    if !viewport.is_finite() || !density.is_finite() {
402        return;
403    }
404    let pixels = density > 0.0;
405    let to_px = |value: f32| if pixels { value * density } else { value };
406    let round_px = |value: f32| if pixels { value.round() } else { value };
407    let viewport_px = round_px(to_px(viewport));
408    let centre_line = if pixels {
409        (viewport_px * 0.5).floor()
410    } else {
411        viewport_px * 0.5
412    };
413    for (index, (top, height)) in rows.into_iter().enumerate() {
414        let Some(placed) =
415            crate::round_scaling_list::place_row_with(params, viewport, top, height, density)
416        else {
417            continue;
418        };
419        let height_px = round_px(to_px(height));
420        let size = round_px(height_px * placed.scale);
421        let drawn_top = to_px(placed.top);
422        let carried = if pixels { odd_pixel(height_px) } else { 0.0 };
423        let stacked_top = drawn_top - carried + if pixels { odd_pixel(size) } else { 0.0 };
424        if stacked_top > viewport_px || stacked_top + size < 0.0 {
425            if out.is_empty() {
426                continue;
427            }
428            break;
429        }
430        out.push(IndicatorItem {
431            index,
432            start_offset: drawn_top - carried - centre_line,
433            size,
434        });
435    }
436}
437
438fn odd_pixel(pixels: f32) -> f32 {
439    let half = pixels * 0.5;
440    half - half.floor()
441}
442
443fn finite(value: f32) -> f32 {
444    if value.is_finite() { value } else { 0.0 }
445}
446
447/// One piece of the indicator, ready to draw.
448///
449/// A segment shorter than its own stroke cannot be drawn as an arc without
450/// looking like a blob, so Wear swaps it for a circle that shrinks and fades
451/// out together. Callers draw whichever variant they are handed.
452#[derive(Clone, Copy, Debug, PartialEq)]
453pub enum IndicatorSegment {
454    /// A stroked arc with a round cap, already inset so the caps land on the
455    /// nominal bounds. `start` and `sweep` are radians, `0` at 3 o'clock.
456    Arc {
457        /// Angle at which the arc starts, radians.
458        start: f32,
459        /// Angle the arc covers from `start`, radians.
460        sweep: f32,
461        /// Opacity from 0.0 (invisible) to 1.0 (opaque).
462        alpha: f32,
463    },
464    /// A filled circle standing in for an arc too short to draw.
465    Dot {
466        /// Angle of the dot's centre, radians.
467        angle: f32,
468        /// Radius, in the same unit as the arc's stroke width.
469        radius: f32,
470        /// Opacity from 0.0 (invisible) to 1.0 (opaque).
471        alpha: f32,
472    },
473}
474
475/// Which part of the indicator a segment belongs to, so a caller can colour the
476/// thumb and the track differently without re-deriving the order.
477#[derive(Clone, Copy, Debug, PartialEq, Eq)]
478pub enum IndicatorPart {
479    /// The two track segments, one on each side of the thumb.
480    Track,
481    /// The part of the indicator that shows the scroll position.
482    Thumb,
483}
484
485/// The whole indicator as a list of drawable pieces: track, thumb, track.
486///
487/// It is three separate segments with a gap at each end of the thumb, not a
488/// thumb painted over a continuous rail — drawing a full-length track under a
489/// thumb gives a visibly different picture where the gaps should be.
490///
491/// `alpha` scales every piece, which is how the indicator fades out after the
492/// list has been still.
493pub fn indicator_segments(
494    arc: IndicatorArc,
495    geometry: IndicatorGeometry,
496    alpha: f32,
497) -> [(IndicatorPart, IndicatorSegment); 3] {
498    let alpha = if alpha.is_finite() {
499        alpha.clamp(0.0, 1.0)
500    } else {
501        0.0
502    };
503    let thumb = if geometry.thumb.is_finite() {
504        geometry.thumb.clamp(0.0, 1.0)
505    } else {
506        0.0
507    };
508    let offset = if geometry.offset.is_finite() {
509        geometry.offset.clamp(0.0, 1.0 - thumb)
510    } else {
511        0.0
512    };
513    let sweep = arc.sweep();
514    let top = arc.start_angle();
515    let thumb_start = top + sweep * offset;
516    let thumb_sweep = sweep * thumb;
517    let below_start = thumb_start + thumb_sweep;
518    [
519        (
520            IndicatorPart::Track,
521            segment(top, thumb_start - top, arc.width, arc.segment_inset, alpha),
522        ),
523        (
524            IndicatorPart::Thumb,
525            segment(
526                thumb_start,
527                thumb_sweep,
528                arc.width,
529                arc.segment_inset,
530                alpha,
531            ),
532        ),
533        (
534            IndicatorPart::Track,
535            segment(
536                below_start,
537                top + sweep - below_start,
538                arc.width,
539                arc.segment_inset,
540                alpha,
541            ),
542        ),
543    ]
544}
545
546fn segment(start: f32, sweep: f32, width: f32, inset: f32, alpha: f32) -> IndicatorSegment {
547    if sweep <= 0.0 || inset <= 0.0 {
548        return IndicatorSegment::Arc {
549            start,
550            sweep: 0.0,
551            alpha: 0.0,
552        };
553    }
554    if sweep < inset {
555        let fill = sweep / inset;
556        return IndicatorSegment::Dot {
557            angle: start + sweep * 0.5,
558            radius: width * 0.5 * fill,
559            alpha: alpha * fill,
560        };
561    }
562    IndicatorSegment::Arc {
563        start: start + inset * 0.5,
564        sweep: sweep - inset,
565        alpha,
566    }
567}
568
569#[cfg(test)]
570#[path = "tests/round_scroll_indicator_tests.rs"]
571mod tests;