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;