Skip to main content

cranpose_ui/widgets/wear/
scroll_indicator.rs

1//! The curved indicator a round watch puts at 3 o'clock.
2//!
3//! The geometry is [`crate::round_scroll_indicator`], which is golden-tested
4//! against where the shipping Compose build puts pixels on 454x454 and 384x384
5//! displays. This module is the widget around it: it reads a list's layout,
6//! turns it into three segments, and draws them.
7//!
8//! Two things about this indicator are not guessable and are worth restating
9//! where the drawing is. It is **three segments with a gap at each end of the
10//! thumb**, not a thumb painted over a continuous rail — drawing a full-length
11//! track underneath gives a visibly different picture. And a segment shorter
12//! than its own stroke is drawn as a **circle that shrinks and fades on the
13//! same fraction**, so a segment leaving the screen dwindles to a dot instead
14//! of stopping at one round cap's width.
15
16#![allow(non_snake_case)]
17
18use crate::composable;
19use crate::modifier::{Brush, Color, Modifier, Point};
20use crate::round_scroll_indicator::{
21    indicator_arc, indicator_segments, scaling_list_geometry, IndicatorGeometry, IndicatorPart,
22    IndicatorSegment,
23};
24use crate::widgets::wear::scaling_list::WearScalingListState;
25use crate::widgets::wear::theme::WearColors;
26use crate::widgets::Canvas;
27use cranpose_core::NodeId;
28use cranpose_ui_graphics::{DrawScope, Stroke, StrokeCap};
29
30/// How a [`ScrollIndicator`] is drawn.
31#[derive(Clone, Copy, Debug, PartialEq)]
32pub struct ScrollIndicatorSpec {
33    pub colors: WearColors,
34    /// How visible the whole indicator is.
35    ///
36    /// Wear fades this to zero two seconds after the last scroll, so a
37    /// screenshot of a settled screen shows no indicator at all. That is a
38    /// policy, not geometry, and an app with an always-on indicator sets this
39    /// to `1.0` and never animates it — which is why the widget takes an alpha
40    /// rather than owning a timer.
41    pub alpha: f32,
42}
43
44impl Default for ScrollIndicatorSpec {
45    fn default() -> Self {
46        Self {
47            colors: WearColors::default(),
48            alpha: 1.0,
49        }
50    }
51}
52
53impl ScrollIndicatorSpec {
54    pub fn colors(mut self, colors: WearColors) -> Self {
55        self.colors = colors;
56        self
57    }
58
59    pub fn alpha(mut self, alpha: f32) -> Self {
60        self.alpha = alpha;
61        self
62    }
63}
64
65/// The thumb for a scaling list.
66///
67/// The thumb is measured in **fractional item indices**, which is what
68/// `ScalingLazyColumnStateAdapter` does and what
69/// [`scaling_list_geometry`] ports: its length is the share of the *items* on
70/// screen and its position is how many items are left, so a list whose rows
71/// differ in height puts it somewhere a pixel model cannot. The two answers
72/// agree only on a list of uniform rows that is as tall as its content, and a
73/// Wear list has a header. Measured against the shipping Compose build on a
74/// settled Settings screen, by integrating the indicator's ink across the
75/// stroke band row by row: the thumb's centroid was 5.97 device pixels below
76/// Compose's at 192dp and 7.73 at 227dp under the flat model, and is 0.61 and
77/// 0.83 under this one.
78///
79/// Whether a list is scrollable at all is a separate question, and Wear leaves
80/// it to `ScreenScaffold` rather than to the adapter — so it is asked here,
81/// against the list's own travel, and not inside the geometry.
82pub fn indicator_for_scaling_list(state: &WearScalingListState) -> Option<IndicatorGeometry> {
83    let info = state.layout_info();
84    let travel = info.travel();
85    if travel <= 0.0 || info.viewport <= 0.0 || info.content <= info.viewport {
86        return None;
87    }
88    state.with_indicator_list(scaling_list_geometry)
89}
90
91/// Draws the indicator into a scope whose bounds are the whole display.
92///
93/// Split out from the composable so it can be tested against a bare
94/// `DrawScopeDefault` and reused by an app that still draws its own screens.
95pub fn draw_scroll_indicator(
96    scope: &mut dyn DrawScope,
97    geometry: IndicatorGeometry,
98    spec: ScrollIndicatorSpec,
99) {
100    let size = scope.size();
101    let radius = size.width.min(size.height) * 0.5;
102    if radius <= 0.0 || !radius.is_finite() || spec.alpha <= 0.0 {
103        return;
104    }
105    let centre = Point {
106        x: size.width * 0.5,
107        y: size.height * 0.5,
108    };
109    let arc = indicator_arc(radius);
110    if arc.centreline() <= 0.0 {
111        return;
112    }
113    let stroke = Stroke::new(arc.width()).with_cap(StrokeCap::Round);
114    for (part, segment) in indicator_segments(arc, geometry, spec.alpha) {
115        let color = match part {
116            IndicatorPart::Track => spec.colors.indicator_track,
117            IndicatorPart::Thumb => spec.colors.indicator_thumb,
118        };
119        match segment {
120            IndicatorSegment::Arc {
121                start,
122                sweep,
123                alpha,
124            } => {
125                if sweep <= 0.0 || alpha <= 0.0 {
126                    continue;
127                }
128                scope.draw_arc(
129                    Brush::Solid(with_alpha(color, alpha)),
130                    centre,
131                    arc.centreline(),
132                    start,
133                    sweep,
134                    stroke,
135                );
136            }
137            IndicatorSegment::Dot {
138                angle,
139                radius: dot,
140                alpha,
141            } => {
142                if dot <= 0.0 || alpha <= 0.0 {
143                    continue;
144                }
145                scope.draw_circle(
146                    Brush::Solid(with_alpha(color, alpha)),
147                    Point {
148                        x: centre.x + arc.centreline() * angle.cos(),
149                        y: centre.y + arc.centreline() * angle.sin(),
150                    },
151                    dot,
152                );
153            }
154        }
155    }
156}
157
158fn with_alpha(color: Color, alpha: f32) -> Color {
159    Color::rgba(color.0, color.1, color.2, color.3 * alpha)
160}
161
162/// The curved scroll indicator, sized to the display it sits on.
163///
164/// It draws nothing when the list fits on screen, which is what Wear does.
165#[composable]
166pub fn ScrollIndicator(
167    modifier: Modifier,
168    state: WearScalingListState,
169    spec: ScrollIndicatorSpec,
170) -> NodeId {
171    let draw_state = state.clone();
172    Canvas(
173        modifier.fill_max_size(),
174        move |scope: &mut dyn DrawScope| {
175            if let Some(geometry) = indicator_for_scaling_list(&draw_state) {
176                draw_scroll_indicator(scope, geometry, spec);
177            }
178        },
179    )
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use cranpose_ui_graphics::{DrawPrimitive, DrawScopeDefault, Size};
186
187    fn scene(geometry: IndicatorGeometry, alpha: f32) -> Vec<DrawPrimitive> {
188        let mut scope = DrawScopeDefault::new(Size::new(227.0, 227.0));
189        draw_scroll_indicator(
190            &mut scope,
191            geometry,
192            ScrollIndicatorSpec::default().alpha(alpha),
193        );
194        scope.into_primitives()
195    }
196
197    #[test]
198    fn the_indicator_is_three_segments_not_a_thumb_over_a_rail() {
199        let primitives = scene(
200            IndicatorGeometry {
201                thumb: 0.4,
202                offset: 0.3,
203            },
204            1.0,
205        );
206        assert_eq!(
207            primitives.len(),
208            3,
209            "track, thumb, track — and no full-length rail underneath"
210        );
211    }
212
213    #[test]
214    fn a_segment_shorter_than_its_stroke_becomes_a_dot() {
215        // The thumb pinned to the very top leaves nothing above it.
216        let primitives = scene(
217            IndicatorGeometry {
218                thumb: 0.7,
219                offset: 0.0,
220            },
221            1.0,
222        );
223        // The leading track has zero sweep and draws nothing at all; what is
224        // left is the thumb and the trailing track.
225        assert_eq!(primitives.len(), 2);
226    }
227
228    #[test]
229    fn a_faded_indicator_draws_nothing_at_all() {
230        let primitives = scene(
231            IndicatorGeometry {
232                thumb: 0.4,
233                offset: 0.3,
234            },
235            0.0,
236        );
237        assert!(primitives.is_empty());
238    }
239
240    #[test]
241    fn a_display_with_no_room_does_not_panic() {
242        let mut scope = DrawScopeDefault::new(Size::new(1.0, 1.0));
243        draw_scroll_indicator(
244            &mut scope,
245            IndicatorGeometry {
246                thumb: 0.4,
247                offset: 0.3,
248            },
249            ScrollIndicatorSpec::default(),
250        );
251        assert!(scope.into_primitives().is_empty());
252    }
253}