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