use denise::Role;
use denise_render::{Canvas, TURN};
use crate::widget::{Animation, PaintCtx, Widget};
use crate::widgets::radial::{ring, ring_colors, thickness_for};
const PERIOD_MS: u64 = 1_000;
const FRAME_MS: u64 = 50;
const SWEEP: i32 = TURN * 3 / 4;
#[derive(Clone, Copy, Debug)]
pub struct Spinner {
role: Role,
thickness: Option<i32>,
period_ms: u64,
phase_ms: u64,
last_ms: Option<u64>,
}
impl Spinner {
pub fn new() -> Self {
Self {
role: Role::Primary,
thickness: None,
period_ms: PERIOD_MS,
phase_ms: 0,
last_ms: None,
}
}
pub fn with_role(mut self, role: Role) -> Self {
self.role = role;
self
}
pub fn with_thickness(mut self, thickness: i32) -> Self {
self.thickness = Some(thickness);
self
}
pub fn with_period_ms(mut self, period_ms: u64) -> Self {
self.period_ms = period_ms.max(FRAME_MS);
self
}
#[inline]
pub fn angle(&self) -> i32 {
angle_at(self.phase_ms, self.period_ms)
}
pub fn set_role(&mut self, role: Role) {
self.role = role;
}
pub fn set_period_ms(&mut self, period_ms: u64) {
self.period_ms = period_ms.max(FRAME_MS);
}
}
impl Default for Spinner {
fn default() -> Self {
Self::new()
}
}
fn angle_at(phase_ms: u64, period_ms: u64) -> i32 {
let period = period_ms.max(1);
((phase_ms % period) * TURN as u64 / period) as i32
}
impl<M: 'static> Widget<M> for Spinner {
fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Canvas<'_>) {
let bounds = ctx.bounds;
if bounds.is_empty() {
return;
}
let (centre, radius) = ring(bounds);
if radius <= 0 {
return;
}
let thickness = thickness_for(radius, self.thickness);
let (track, arc) = ring_colors(ctx.theme, ctx.state, self.role);
canvas.stroke_circle(centre, radius, thickness, track);
canvas.stroke_arc(centre, radius, thickness, self.angle(), SWEEP, arc);
}
fn animate(&mut self, now_ms: u64) -> Animation {
let elapsed = match self.last_ms {
Some(last) => now_ms.saturating_sub(last),
None => 0,
};
self.last_ms = Some(now_ms);
let before = self.angle();
let period = self.period_ms.max(1);
self.phase_ms = (self.phase_ms + elapsed.min(period)) % period;
Animation {
repaint: self.angle() != before,
next_ms: Some(now_ms + FRAME_MS),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tick(spinner: &mut Spinner, now_ms: u64) -> Animation {
Widget::<()>::animate(spinner, now_ms)
}
#[test]
fn one_period_is_one_revolution() {
let mut spinner = Spinner::new().with_period_ms(1_000);
tick(&mut spinner, 5_000);
assert_eq!(spinner.angle(), 0, "the first frame must not jump");
for frame in 1..=20 {
tick(&mut spinner, 5_000 + frame * 50);
}
assert_eq!(spinner.angle(), 0, "a lap must land where it started");
}
#[test]
fn the_first_frame_does_not_jump_by_the_applications_clock() {
let mut spinner = Spinner::new().with_period_ms(1_000);
tick(&mut spinner, 300);
assert_eq!(spinner.angle(), 0, "a spinner starts where it starts");
tick(&mut spinner, 550);
assert_eq!(spinner.angle(), TURN / 4, "250 ms of a second is a quarter");
}
#[test]
fn the_angle_wraps_cleanly_and_never_leaves_the_turn() {
let mut spinner = Spinner::new();
tick(&mut spinner, 0);
let mut previous = spinner.angle();
let mut wraps = 0;
for frame in 1..=2_000u64 {
tick(&mut spinner, frame * FRAME_MS);
let angle = spinner.angle();
assert!(
(0..TURN).contains(&angle),
"frame {frame}: angle {angle} left the turn"
);
if angle < previous {
wraps += 1;
}
previous = angle;
}
assert!(
wraps >= 90,
"2000 frames at 20 fps is 100 laps, saw {wraps}"
);
}
#[test]
fn a_longer_period_turns_more_slowly() {
let step = |period: u64| {
let mut spinner = Spinner::new().with_period_ms(period);
tick(&mut spinner, 0);
tick(&mut spinner, FRAME_MS);
spinner.angle()
};
let fast = step(500);
let slow = step(4_000);
assert!(fast > slow, "{fast} is not more per frame than {slow}");
assert_eq!(fast, TURN / 10, "50 ms of a 500 ms period is a tenth");
assert_eq!(slow, TURN / 80);
}
#[test]
fn an_impossibly_short_period_is_clamped_to_a_frame() {
for asked in [0, 1, 10, FRAME_MS - 1] {
let spinner = Spinner::new().with_period_ms(asked);
assert_eq!(spinner.period_ms, FRAME_MS, "asked for {asked}");
}
let mut spinner = Spinner::new();
spinner.set_period_ms(0);
assert_eq!(spinner.period_ms, FRAME_MS);
}
#[test]
fn a_spinner_never_hands_the_cpu_back_on_its_own() {
let mut spinner = Spinner::new();
for frame in 0..200u64 {
let animation = tick(&mut spinner, frame * FRAME_MS);
assert!(
animation.next_ms.is_some(),
"frame {frame}: a spinner must keep asking"
);
}
}
#[test]
fn an_early_or_repeated_frame_never_rewinds_the_arc() {
let mut spinner = Spinner::new();
tick(&mut spinner, 1_000);
let start = spinner.angle();
let animation = tick(&mut spinner, 1_000);
assert_eq!(spinner.angle(), start);
assert!(!animation.repaint, "no time passed, so nothing to repaint");
tick(&mut spinner, 500);
assert_eq!(spinner.angle(), start, "a backwards clock must not rewind");
}
#[test]
fn a_long_gap_resumes_rather_than_catching_up() {
assert_eq!(angle_at(0, 1_000), 0);
assert_eq!(angle_at(500, 1_000), TURN / 2);
assert_eq!(angle_at(1_000, 1_000), 0, "a whole period is a whole turn");
assert_eq!(angle_at(1_500, 1_000), TURN / 2, "and it wraps by phase");
let mut spinner = Spinner::new();
tick(&mut spinner, 0);
tick(&mut spinner, 60 * 60 * 1_000);
assert_eq!(spinner.angle(), 0);
}
#[test]
fn the_arc_leaves_a_gap_to_see_it_turn_by() {
const { assert!(SWEEP < TURN, "a full sweep cannot be seen rotating") };
const { assert!(SWEEP > TURN / 2, "and too short a one is a fragment") };
}
}