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_geometry, indicator_segments, 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 travel is measured between the first and last item's **centres**, not
68/// across a flat content length, because a centring list holds those two rows
69/// on the centre line rather than against the display edges. Reading it the
70/// flat way puts the thumb a little short at both ends, on every screen.
71pub fn indicator_for_scaling_list(state: &WearScalingListState) -> Option<IndicatorGeometry> {
72    let info = state.layout_info();
73    let travel = info.travel();
74    if travel <= 0.0 || info.viewport <= 0.0 || info.content <= info.viewport {
75        return None;
76    }
77    indicator_geometry(info.content, info.viewport, info.scrolled())
78}
79
80/// Draws the indicator into a scope whose bounds are the whole display.
81///
82/// Split out from the composable so it can be tested against a bare
83/// `DrawScopeDefault` and reused by an app that still draws its own screens.
84pub fn draw_scroll_indicator(
85    scope: &mut dyn DrawScope,
86    geometry: IndicatorGeometry,
87    spec: ScrollIndicatorSpec,
88) {
89    let size = scope.size();
90    let radius = size.width.min(size.height) * 0.5;
91    if radius <= 0.0 || !radius.is_finite() || spec.alpha <= 0.0 {
92        return;
93    }
94    let centre = Point {
95        x: size.width * 0.5,
96        y: size.height * 0.5,
97    };
98    let arc = indicator_arc(radius);
99    if arc.centreline() <= 0.0 {
100        return;
101    }
102    let stroke = Stroke::new(arc.width()).with_cap(StrokeCap::Round);
103    for (part, segment) in indicator_segments(arc, geometry, spec.alpha) {
104        let color = match part {
105            IndicatorPart::Track => spec.colors.indicator_track,
106            IndicatorPart::Thumb => spec.colors.indicator_thumb,
107        };
108        match segment {
109            IndicatorSegment::Arc {
110                start,
111                sweep,
112                alpha,
113            } => {
114                if sweep <= 0.0 || alpha <= 0.0 {
115                    continue;
116                }
117                scope.draw_arc(
118                    Brush::Solid(with_alpha(color, alpha)),
119                    centre,
120                    arc.centreline(),
121                    start,
122                    sweep,
123                    stroke,
124                );
125            }
126            IndicatorSegment::Dot {
127                angle,
128                radius: dot,
129                alpha,
130            } => {
131                if dot <= 0.0 || alpha <= 0.0 {
132                    continue;
133                }
134                scope.draw_circle(
135                    Brush::Solid(with_alpha(color, alpha)),
136                    Point {
137                        x: centre.x + arc.centreline() * angle.cos(),
138                        y: centre.y + arc.centreline() * angle.sin(),
139                    },
140                    dot,
141                );
142            }
143        }
144    }
145}
146
147fn with_alpha(color: Color, alpha: f32) -> Color {
148    Color::rgba(color.0, color.1, color.2, color.3 * alpha)
149}
150
151/// The curved scroll indicator, sized to the display it sits on.
152///
153/// It draws nothing when the list fits on screen, which is what Wear does.
154#[composable]
155pub fn ScrollIndicator(
156    modifier: Modifier,
157    state: WearScalingListState,
158    spec: ScrollIndicatorSpec,
159) -> NodeId {
160    let draw_state = state.clone();
161    Canvas(
162        modifier.fill_max_size(),
163        move |scope: &mut dyn DrawScope| {
164            if let Some(geometry) = indicator_for_scaling_list(&draw_state) {
165                draw_scroll_indicator(scope, geometry, spec);
166            }
167        },
168    )
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use cranpose_ui_graphics::{DrawPrimitive, DrawScopeDefault, Size};
175
176    fn scene(geometry: IndicatorGeometry, alpha: f32) -> Vec<DrawPrimitive> {
177        let mut scope = DrawScopeDefault::new(Size::new(227.0, 227.0));
178        draw_scroll_indicator(
179            &mut scope,
180            geometry,
181            ScrollIndicatorSpec::default().alpha(alpha),
182        );
183        scope.into_primitives()
184    }
185
186    #[test]
187    fn the_indicator_is_three_segments_not_a_thumb_over_a_rail() {
188        let primitives = scene(
189            IndicatorGeometry {
190                thumb: 0.4,
191                offset: 0.3,
192            },
193            1.0,
194        );
195        assert_eq!(
196            primitives.len(),
197            3,
198            "track, thumb, track — and no full-length rail underneath"
199        );
200    }
201
202    #[test]
203    fn a_segment_shorter_than_its_stroke_becomes_a_dot() {
204        // The thumb pinned to the very top leaves nothing above it.
205        let primitives = scene(
206            IndicatorGeometry {
207                thumb: 0.7,
208                offset: 0.0,
209            },
210            1.0,
211        );
212        // The leading track has zero sweep and draws nothing at all; what is
213        // left is the thumb and the trailing track.
214        assert_eq!(primitives.len(), 2);
215    }
216
217    #[test]
218    fn a_faded_indicator_draws_nothing_at_all() {
219        let primitives = scene(
220            IndicatorGeometry {
221                thumb: 0.4,
222                offset: 0.3,
223            },
224            0.0,
225        );
226        assert!(primitives.is_empty());
227    }
228
229    #[test]
230    fn a_display_with_no_room_does_not_panic() {
231        let mut scope = DrawScopeDefault::new(Size::new(1.0, 1.0));
232        draw_scroll_indicator(
233            &mut scope,
234            IndicatorGeometry {
235                thumb: 0.4,
236                offset: 0.3,
237            },
238            ScrollIndicatorSpec::default(),
239        );
240        assert!(scope.into_primitives().is_empty());
241    }
242}