1use std::f32::consts::FRAC_PI_2;
20
21pub const INDICATOR_HEIGHT_DP: f32 = 50.0;
24pub const INDICATOR_WIDTH_DP: f32 = 6.0;
27pub const INDICATOR_NARROW_WIDTH_DP: f32 = 5.0;
28pub const INDICATOR_LARGE_SCREEN_DP: f32 = 225.0;
30pub const INDICATOR_EDGE_PADDING_DP: f32 = 2.0;
33pub const INDICATOR_GAP_DP: f32 = 3.0;
36pub const INDICATOR_MIN_THUMB: f32 = 0.3;
39pub const INDICATOR_MAX_THUMB: f32 = 0.7;
40
41pub fn indicator_width_dp(display_dp: f32) -> f32 {
43 if display_dp.is_finite() && display_dp >= INDICATOR_LARGE_SCREEN_DP {
44 INDICATOR_WIDTH_DP
45 } else {
46 INDICATOR_NARROW_WIDTH_DP
47 }
48}
49
50#[derive(Clone, Copy, Debug, PartialEq)]
52pub struct IndicatorArc {
53 centreline: f32,
55 width: f32,
57 half_sweep: f32,
59 segment_inset: f32,
62}
63
64impl IndicatorArc {
65 pub fn centreline(self) -> f32 {
67 self.centreline
68 }
69
70 pub fn width(self) -> f32 {
72 self.width
73 }
74
75 pub fn segment_inset(self) -> f32 {
77 self.segment_inset
78 }
79
80 pub fn start_angle(self) -> f32 {
83 -self.half_sweep
84 }
85
86 pub fn sweep(self) -> f32 {
88 self.half_sweep * 2.0
89 }
90
91 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
112pub 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#[derive(Clone, Copy, Debug, PartialEq)]
149pub struct IndicatorGeometry {
150 pub thumb: f32,
152 pub offset: f32,
154}
155
156pub fn indicator_geometry(content: f32, viewport: f32, scrolled: f32) -> Option<IndicatorGeometry> {
162 if !(content.is_finite() && viewport.is_finite() && scrolled.is_finite()) {
163 return None;
164 }
165 if viewport <= 0.0 || content <= viewport {
166 return None;
167 }
168 let thumb = (viewport / content).clamp(INDICATOR_MIN_THUMB, INDICATOR_MAX_THUMB);
169 let travel = content - viewport;
170 let progress = (scrolled / travel).clamp(0.0, 1.0);
171 Some(IndicatorGeometry {
172 thumb,
173 offset: progress * (1.0 - thumb),
174 })
175}
176
177#[derive(Clone, Copy, Debug, PartialEq)]
183pub enum IndicatorSegment {
184 Arc { start: f32, sweep: f32, alpha: f32 },
187 Dot {
189 angle: f32,
191 radius: f32,
193 alpha: f32,
194 },
195}
196
197#[derive(Clone, Copy, Debug, PartialEq, Eq)]
200pub enum IndicatorPart {
201 Track,
202 Thumb,
203}
204
205pub fn indicator_segments(
214 arc: IndicatorArc,
215 geometry: IndicatorGeometry,
216 alpha: f32,
217) -> [(IndicatorPart, IndicatorSegment); 3] {
218 let alpha = if alpha.is_finite() {
219 alpha.clamp(0.0, 1.0)
220 } else {
221 0.0
222 };
223 let thumb = if geometry.thumb.is_finite() {
224 geometry.thumb.clamp(0.0, 1.0)
225 } else {
226 0.0
227 };
228 let offset = if geometry.offset.is_finite() {
229 geometry.offset.clamp(0.0, 1.0 - thumb)
230 } else {
231 0.0
232 };
233 let sweep = arc.sweep();
234 let top = arc.start_angle();
235 let thumb_start = top + sweep * offset;
236 let thumb_sweep = sweep * thumb;
237 let below_start = thumb_start + thumb_sweep;
238 [
239 (
240 IndicatorPart::Track,
241 segment(top, thumb_start - top, arc.width, arc.segment_inset, alpha),
242 ),
243 (
244 IndicatorPart::Thumb,
245 segment(
246 thumb_start,
247 thumb_sweep,
248 arc.width,
249 arc.segment_inset,
250 alpha,
251 ),
252 ),
253 (
254 IndicatorPart::Track,
255 segment(
256 below_start,
257 top + sweep - below_start,
258 arc.width,
259 arc.segment_inset,
260 alpha,
261 ),
262 ),
263 ]
264}
265
266fn segment(start: f32, sweep: f32, width: f32, inset: f32, alpha: f32) -> IndicatorSegment {
268 if sweep <= 0.0 || inset <= 0.0 {
269 return IndicatorSegment::Arc {
270 start,
271 sweep: 0.0,
272 alpha: 0.0,
273 };
274 }
275 if sweep < inset {
276 let fill = sweep / inset;
280 return IndicatorSegment::Dot {
281 angle: start + sweep * 0.5,
282 radius: width * 0.5 * fill,
283 alpha: alpha * fill,
284 };
285 }
286 IndicatorSegment::Arc {
289 start: start + inset * 0.5,
290 sweep: sweep - inset,
291 alpha,
292 }
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 const LARGE_RADIUS_DP: f32 = 113.5; const SMALL_RADIUS_DP: f32 = 96.0; #[test]
304 fn stroke_width_switches_at_the_wear_large_screen_breakpoint() {
305 assert_eq!(indicator_width_dp(224.99), INDICATOR_NARROW_WIDTH_DP);
306 assert_eq!(indicator_width_dp(225.0), INDICATOR_WIDTH_DP);
307 assert_eq!(indicator_width_dp(f32::NAN), INDICATOR_NARROW_WIDTH_DP);
308 }
309
310 #[test]
311 fn the_track_lands_where_the_shipping_compose_build_draws_it() {
312 let large = indicator_arc(LARGE_RADIUS_DP);
316 assert!((large.centreline() - 108.5).abs() < 0.01, "{large:?}");
317 assert!((large.width() - 6.0).abs() < 0.01, "{large:?}");
318
319 let small = indicator_arc(SMALL_RADIUS_DP);
320 assert!((small.centreline() - 91.5).abs() < 0.01, "{small:?}");
321 assert!((small.width() - 5.0).abs() < 0.01, "{small:?}");
322 }
323
324 #[test]
325 fn the_sweep_is_a_height_in_dp_not_a_fixed_angle() {
326 let large = indicator_arc(LARGE_RADIUS_DP).sweep().to_degrees();
329 let small = indicator_arc(SMALL_RADIUS_DP).sweep().to_degrees();
330 assert!((large - 30.54).abs() < 0.05, "{large}");
331 assert!((small - 35.73).abs() < 0.05, "{small}");
332 assert!(small > large);
333 }
334
335 #[test]
336 fn a_list_that_fits_on_screen_shows_no_indicator_at_all() {
337 assert_eq!(indicator_geometry(100.0, 100.0, 0.0), None);
338 assert_eq!(indicator_geometry(80.0, 100.0, 0.0), None);
339 assert_eq!(indicator_geometry(f32::NAN, 100.0, 0.0), None);
340 assert_eq!(indicator_geometry(200.0, 0.0, 0.0), None);
341 }
342
343 #[test]
344 fn the_thumb_is_the_viewport_share_clamped_at_both_ends() {
345 let half = indicator_geometry(200.0, 100.0, 0.0).unwrap();
347 assert!((half.thumb - 0.5).abs() < 1e-6, "{half:?}");
348 let long = indicator_geometry(10_000.0, 100.0, 0.0).unwrap();
351 assert!((long.thumb - INDICATOR_MIN_THUMB).abs() < 1e-6, "{long:?}");
352 let short = indicator_geometry(105.0, 100.0, 0.0).unwrap();
353 assert!(
354 (short.thumb - INDICATOR_MAX_THUMB).abs() < 1e-6,
355 "{short:?}"
356 );
357 }
358
359 #[test]
360 fn the_thumb_reaches_the_bottom_of_the_track_and_no_further() {
361 let bottom = indicator_geometry(200.0, 100.0, 100.0).unwrap();
362 assert!(
363 (bottom.offset + bottom.thumb - 1.0).abs() < 1e-6,
364 "{bottom:?}"
365 );
366 let past = indicator_geometry(200.0, 100.0, 500.0).unwrap();
368 assert_eq!(past, bottom);
369 }
370
371 #[test]
372 fn the_indicator_is_three_segments_with_a_gap_either_side_of_the_thumb() {
373 let arc = indicator_arc(LARGE_RADIUS_DP);
374 let geometry = IndicatorGeometry {
375 thumb: 0.4,
376 offset: 0.3,
377 };
378 let parts = indicator_segments(arc, geometry, 1.0);
379 assert_eq!(parts[0].0, IndicatorPart::Track);
380 assert_eq!(parts[1].0, IndicatorPart::Thumb);
381 assert_eq!(parts[2].0, IndicatorPart::Track);
382
383 let ink_bounds = |segment: IndicatorSegment| match segment {
387 IndicatorSegment::Arc { start, sweep, .. } => {
388 (start - arc.cap_sweep() * 0.5, sweep + arc.cap_sweep())
389 }
390 other => panic!("expected an arc, got {other:?}"),
391 };
392 let (above_start, above_sweep) = ink_bounds(parts[0].1);
393 let (thumb_start, thumb_sweep) = ink_bounds(parts[1].1);
394 let (below_start, below_sweep) = ink_bounds(parts[2].1);
395 let gap = arc.segment_inset() - arc.cap_sweep();
396
397 assert!((above_start - arc.start_angle() - gap * 0.5).abs() < 1e-4);
398 assert!((thumb_start - (above_start + above_sweep) - gap).abs() < 1e-4);
399 assert!((below_start - (thumb_start + thumb_sweep) - gap).abs() < 1e-4);
400 assert!(
401 (below_start + below_sweep + gap * 0.5 - (arc.start_angle() + arc.sweep())).abs()
402 < 1e-4,
403 "the track has to end where it should"
404 );
405 }
406
407 #[test]
408 fn a_segment_shorter_than_its_stroke_becomes_a_shrinking_dot() {
409 let arc = indicator_arc(LARGE_RADIUS_DP);
410 let parts = indicator_segments(
412 arc,
413 IndicatorGeometry {
414 thumb: 0.7,
415 offset: 0.0,
416 },
417 1.0,
418 );
419 match parts[0].1 {
420 IndicatorSegment::Dot { radius, alpha, .. } => {
421 assert!(
422 radius <= arc.width() * 0.5,
423 "a dot never exceeds the stroke"
424 );
425 assert!(alpha < 1.0, "it fades on the same fraction as it shrinks");
426 }
427 IndicatorSegment::Arc { sweep, .. } => {
428 assert!(sweep <= 0.0, "an arc this short should have been a dot");
429 }
430 }
431 }
432
433 #[test]
434 fn fading_the_indicator_fades_every_piece_of_it() {
435 let arc = indicator_arc(LARGE_RADIUS_DP);
436 let geometry = IndicatorGeometry {
437 thumb: 0.4,
438 offset: 0.3,
439 };
440 for (_, segment) in indicator_segments(arc, geometry, 0.25) {
441 let alpha = match segment {
442 IndicatorSegment::Arc { alpha, .. } => alpha,
443 IndicatorSegment::Dot { alpha, .. } => alpha,
444 };
445 assert!(alpha <= 0.25 + 1e-6, "{segment:?}");
446 }
447 }
448
449 #[test]
450 fn a_display_too_small_to_hold_the_track_degrades_instead_of_panicking() {
451 let tiny = indicator_arc(1.0);
452 assert_eq!(tiny.centreline(), 0.0);
453 assert_eq!(tiny.sweep(), 0.0);
454 assert_eq!(tiny.cap_sweep(), 0.0);
455 let parts = indicator_segments(
457 tiny,
458 IndicatorGeometry {
459 thumb: 0.4,
460 offset: 0.3,
461 },
462 1.0,
463 );
464 for (_, segment) in parts {
465 assert!(matches!(segment, IndicatorSegment::Arc { sweep: 0.0, .. }));
466 }
467 }
468
469 #[test]
470 fn invalid_public_inputs_never_emit_non_finite_draw_values() {
471 for radius in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -1.0] {
472 let arc = indicator_arc(radius);
473 assert_eq!(arc.sweep(), 0.0);
474 assert_eq!(arc.segment_inset(), 0.0);
475 }
476
477 let parts = indicator_segments(
478 indicator_arc(LARGE_RADIUS_DP),
479 IndicatorGeometry {
480 thumb: f32::NAN,
481 offset: f32::INFINITY,
482 },
483 f32::NAN,
484 );
485 for (_, part) in parts {
486 match part {
487 IndicatorSegment::Arc {
488 start,
489 sweep,
490 alpha,
491 } => assert!(start.is_finite() && sweep.is_finite() && alpha == 0.0),
492 IndicatorSegment::Dot {
493 angle,
494 radius,
495 alpha,
496 } => assert!(angle.is_finite() && radius.is_finite() && alpha == 0.0),
497 }
498 }
499 }
500}