use std::f32::consts::FRAC_PI_2;
pub const INDICATOR_HEIGHT_DP: f32 = 50.0;
pub const INDICATOR_WIDTH_DP: f32 = 6.0;
pub const INDICATOR_NARROW_WIDTH_DP: f32 = 5.0;
pub const INDICATOR_LARGE_SCREEN_DP: f32 = 225.0;
pub const INDICATOR_EDGE_PADDING_DP: f32 = 2.0;
pub const INDICATOR_GAP_DP: f32 = 3.0;
pub const INDICATOR_MIN_THUMB: f32 = 0.3;
pub const INDICATOR_MAX_THUMB: f32 = 0.7;
pub fn indicator_width_dp(display_dp: f32) -> f32 {
if display_dp.is_finite() && display_dp >= INDICATOR_LARGE_SCREEN_DP {
INDICATOR_WIDTH_DP
} else {
INDICATOR_NARROW_WIDTH_DP
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct IndicatorArc {
centreline: f32,
width: f32,
half_sweep: f32,
segment_inset: f32,
}
impl IndicatorArc {
pub fn centreline(self) -> f32 {
self.centreline
}
pub fn width(self) -> f32 {
self.width
}
pub fn segment_inset(self) -> f32 {
self.segment_inset
}
pub fn start_angle(self) -> f32 {
-self.half_sweep
}
pub fn sweep(self) -> f32 {
self.half_sweep * 2.0
}
pub fn cap_sweep(self) -> f32 {
if self.centreline > 0.0 {
self.width / self.centreline
} else {
0.0
}
}
}
fn height_to_sweep(height: f32, radius: f32) -> f32 {
if radius <= 0.0 || !radius.is_finite() {
return 0.0;
}
(height * 0.5 / radius).clamp(-1.0, 1.0).asin() * 2.0
}
pub fn indicator_arc(radius: f32) -> IndicatorArc {
let width = indicator_width_dp(radius * 2.0);
let usable_radius = radius - INDICATOR_EDGE_PADDING_DP;
let centreline = usable_radius - width * 0.5;
if centreline <= 0.0 || !centreline.is_finite() {
return IndicatorArc {
centreline: 0.0,
width,
half_sweep: 0.0,
segment_inset: 0.0,
};
}
let segment_inset = height_to_sweep(width + INDICATOR_GAP_DP, usable_radius);
let half_sweep = ((height_to_sweep(INDICATOR_HEIGHT_DP, usable_radius) + segment_inset) * 0.5)
.min(FRAC_PI_2);
IndicatorArc {
centreline,
width,
half_sweep,
segment_inset,
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct IndicatorGeometry {
pub thumb: f32,
pub offset: f32,
}
pub fn indicator_geometry(content: f32, viewport: f32, scrolled: f32) -> Option<IndicatorGeometry> {
if !(content.is_finite() && viewport.is_finite() && scrolled.is_finite()) {
return None;
}
if viewport <= 0.0 || content <= viewport {
return None;
}
let thumb = (viewport / content).clamp(INDICATOR_MIN_THUMB, INDICATOR_MAX_THUMB);
let travel = content - viewport;
let progress = (scrolled / travel).clamp(0.0, 1.0);
Some(IndicatorGeometry {
thumb,
offset: progress * (1.0 - thumb),
})
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum IndicatorSegment {
Arc { start: f32, sweep: f32, alpha: f32 },
Dot {
angle: f32,
radius: f32,
alpha: f32,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum IndicatorPart {
Track,
Thumb,
}
pub fn indicator_segments(
arc: IndicatorArc,
geometry: IndicatorGeometry,
alpha: f32,
) -> [(IndicatorPart, IndicatorSegment); 3] {
let alpha = if alpha.is_finite() {
alpha.clamp(0.0, 1.0)
} else {
0.0
};
let thumb = if geometry.thumb.is_finite() {
geometry.thumb.clamp(0.0, 1.0)
} else {
0.0
};
let offset = if geometry.offset.is_finite() {
geometry.offset.clamp(0.0, 1.0 - thumb)
} else {
0.0
};
let sweep = arc.sweep();
let top = arc.start_angle();
let thumb_start = top + sweep * offset;
let thumb_sweep = sweep * thumb;
let below_start = thumb_start + thumb_sweep;
[
(
IndicatorPart::Track,
segment(top, thumb_start - top, arc.width, arc.segment_inset, alpha),
),
(
IndicatorPart::Thumb,
segment(
thumb_start,
thumb_sweep,
arc.width,
arc.segment_inset,
alpha,
),
),
(
IndicatorPart::Track,
segment(
below_start,
top + sweep - below_start,
arc.width,
arc.segment_inset,
alpha,
),
),
]
}
fn segment(start: f32, sweep: f32, width: f32, inset: f32, alpha: f32) -> IndicatorSegment {
if sweep <= 0.0 || inset <= 0.0 {
return IndicatorSegment::Arc {
start,
sweep: 0.0,
alpha: 0.0,
};
}
if sweep < inset {
let fill = sweep / inset;
return IndicatorSegment::Dot {
angle: start + sweep * 0.5,
radius: width * 0.5 * fill,
alpha: alpha * fill,
};
}
IndicatorSegment::Arc {
start: start + inset * 0.5,
sweep: sweep - inset,
alpha,
}
}
#[cfg(test)]
mod tests {
use super::*;
const LARGE_RADIUS_DP: f32 = 113.5; const SMALL_RADIUS_DP: f32 = 96.0;
#[test]
fn stroke_width_switches_at_the_wear_large_screen_breakpoint() {
assert_eq!(indicator_width_dp(224.99), INDICATOR_NARROW_WIDTH_DP);
assert_eq!(indicator_width_dp(225.0), INDICATOR_WIDTH_DP);
assert_eq!(indicator_width_dp(f32::NAN), INDICATOR_NARROW_WIDTH_DP);
}
#[test]
fn the_track_lands_where_the_shipping_compose_build_draws_it() {
let large = indicator_arc(LARGE_RADIUS_DP);
assert!((large.centreline() - 108.5).abs() < 0.01, "{large:?}");
assert!((large.width() - 6.0).abs() < 0.01, "{large:?}");
let small = indicator_arc(SMALL_RADIUS_DP);
assert!((small.centreline() - 91.5).abs() < 0.01, "{small:?}");
assert!((small.width() - 5.0).abs() < 0.01, "{small:?}");
}
#[test]
fn the_sweep_is_a_height_in_dp_not_a_fixed_angle() {
let large = indicator_arc(LARGE_RADIUS_DP).sweep().to_degrees();
let small = indicator_arc(SMALL_RADIUS_DP).sweep().to_degrees();
assert!((large - 30.54).abs() < 0.05, "{large}");
assert!((small - 35.73).abs() < 0.05, "{small}");
assert!(small > large);
}
#[test]
fn a_list_that_fits_on_screen_shows_no_indicator_at_all() {
assert_eq!(indicator_geometry(100.0, 100.0, 0.0), None);
assert_eq!(indicator_geometry(80.0, 100.0, 0.0), None);
assert_eq!(indicator_geometry(f32::NAN, 100.0, 0.0), None);
assert_eq!(indicator_geometry(200.0, 0.0, 0.0), None);
}
#[test]
fn the_thumb_is_the_viewport_share_clamped_at_both_ends() {
let half = indicator_geometry(200.0, 100.0, 0.0).unwrap();
assert!((half.thumb - 0.5).abs() < 1e-6, "{half:?}");
let long = indicator_geometry(10_000.0, 100.0, 0.0).unwrap();
assert!((long.thumb - INDICATOR_MIN_THUMB).abs() < 1e-6, "{long:?}");
let short = indicator_geometry(105.0, 100.0, 0.0).unwrap();
assert!(
(short.thumb - INDICATOR_MAX_THUMB).abs() < 1e-6,
"{short:?}"
);
}
#[test]
fn the_thumb_reaches_the_bottom_of_the_track_and_no_further() {
let bottom = indicator_geometry(200.0, 100.0, 100.0).unwrap();
assert!(
(bottom.offset + bottom.thumb - 1.0).abs() < 1e-6,
"{bottom:?}"
);
let past = indicator_geometry(200.0, 100.0, 500.0).unwrap();
assert_eq!(past, bottom);
}
#[test]
fn the_indicator_is_three_segments_with_a_gap_either_side_of_the_thumb() {
let arc = indicator_arc(LARGE_RADIUS_DP);
let geometry = IndicatorGeometry {
thumb: 0.4,
offset: 0.3,
};
let parts = indicator_segments(arc, geometry, 1.0);
assert_eq!(parts[0].0, IndicatorPart::Track);
assert_eq!(parts[1].0, IndicatorPart::Thumb);
assert_eq!(parts[2].0, IndicatorPart::Track);
let ink_bounds = |segment: IndicatorSegment| match segment {
IndicatorSegment::Arc { start, sweep, .. } => {
(start - arc.cap_sweep() * 0.5, sweep + arc.cap_sweep())
}
other => panic!("expected an arc, got {other:?}"),
};
let (above_start, above_sweep) = ink_bounds(parts[0].1);
let (thumb_start, thumb_sweep) = ink_bounds(parts[1].1);
let (below_start, below_sweep) = ink_bounds(parts[2].1);
let gap = arc.segment_inset() - arc.cap_sweep();
assert!((above_start - arc.start_angle() - gap * 0.5).abs() < 1e-4);
assert!((thumb_start - (above_start + above_sweep) - gap).abs() < 1e-4);
assert!((below_start - (thumb_start + thumb_sweep) - gap).abs() < 1e-4);
assert!(
(below_start + below_sweep + gap * 0.5 - (arc.start_angle() + arc.sweep())).abs()
< 1e-4,
"the track has to end where it should"
);
}
#[test]
fn a_segment_shorter_than_its_stroke_becomes_a_shrinking_dot() {
let arc = indicator_arc(LARGE_RADIUS_DP);
let parts = indicator_segments(
arc,
IndicatorGeometry {
thumb: 0.7,
offset: 0.0,
},
1.0,
);
match parts[0].1 {
IndicatorSegment::Dot { radius, alpha, .. } => {
assert!(
radius <= arc.width() * 0.5,
"a dot never exceeds the stroke"
);
assert!(alpha < 1.0, "it fades on the same fraction as it shrinks");
}
IndicatorSegment::Arc { sweep, .. } => {
assert!(sweep <= 0.0, "an arc this short should have been a dot");
}
}
}
#[test]
fn fading_the_indicator_fades_every_piece_of_it() {
let arc = indicator_arc(LARGE_RADIUS_DP);
let geometry = IndicatorGeometry {
thumb: 0.4,
offset: 0.3,
};
for (_, segment) in indicator_segments(arc, geometry, 0.25) {
let alpha = match segment {
IndicatorSegment::Arc { alpha, .. } => alpha,
IndicatorSegment::Dot { alpha, .. } => alpha,
};
assert!(alpha <= 0.25 + 1e-6, "{segment:?}");
}
}
#[test]
fn a_display_too_small_to_hold_the_track_degrades_instead_of_panicking() {
let tiny = indicator_arc(1.0);
assert_eq!(tiny.centreline(), 0.0);
assert_eq!(tiny.sweep(), 0.0);
assert_eq!(tiny.cap_sweep(), 0.0);
let parts = indicator_segments(
tiny,
IndicatorGeometry {
thumb: 0.4,
offset: 0.3,
},
1.0,
);
for (_, segment) in parts {
assert!(matches!(segment, IndicatorSegment::Arc { sweep: 0.0, .. }));
}
}
#[test]
fn invalid_public_inputs_never_emit_non_finite_draw_values() {
for radius in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -1.0] {
let arc = indicator_arc(radius);
assert_eq!(arc.sweep(), 0.0);
assert_eq!(arc.segment_inset(), 0.0);
}
let parts = indicator_segments(
indicator_arc(LARGE_RADIUS_DP),
IndicatorGeometry {
thumb: f32::NAN,
offset: f32::INFINITY,
},
f32::NAN,
);
for (_, part) in parts {
match part {
IndicatorSegment::Arc {
start,
sweep,
alpha,
} => assert!(start.is_finite() && sweep.is_finite() && alpha == 0.0),
IndicatorSegment::Dot {
angle,
radius,
alpha,
} => assert!(angle.is_finite() && radius.is_finite() && alpha == 0.0),
}
}
}
}