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)]
558#[path = "tests/round_scroll_indicator_tests.rs"]
559mod tests;