use denise::Role;
use denise::{Pen, TURN};
use crate::motion::{Motion, Wake};
use crate::widget::{Animation, PaintCtx, Widget};
use crate::widgets::describe::{
Describe, DynDescribe, Group, Mismatch, Property, PropertyKind, ROLES, Value,
};
use crate::widgets::radial::{ring, ring_colors, thickness_for};
const PERIOD_MS: u64 = 1_000;
const MIN_PERIOD_MS: u64 = Motion::DEFAULT_INTERVAL_MS;
const SWEEP: i32 = TURN * 3 / 4;
#[derive(Clone, Copy, Debug)]
pub struct Spinner {
role: Role,
thickness: Option<i32>,
period_ms: u64,
frame_ms: Option<u64>,
phase_ms: u64,
last_ms: Option<u64>,
}
impl Spinner {
pub fn new() -> Self {
Self {
role: Role::Primary,
thickness: None,
period_ms: PERIOD_MS,
frame_ms: None,
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(MIN_PERIOD_MS);
self
}
pub fn with_frame_ms(mut self, frame_ms: u64) -> Self {
self.frame_ms = Some(frame_ms.max(1));
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(MIN_PERIOD_MS);
}
pub fn set_frame_ms(&mut self, frame_ms: Option<u64>) {
self.frame_ms = frame_ms.map(|ms| ms.max(1));
}
}
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 describe(&self) -> Option<&dyn DynDescribe> {
Some(self)
}
fn describe_mut(&mut self) -> Option<&mut dyn DynDescribe> {
Some(self)
}
fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Pen<'_>) {
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: match self.frame_ms {
None => Wake::Animating,
Some(ms) => Wake::At(now_ms.saturating_add(ms)),
},
}
}
fn snap(&mut self, _now_ms: u64) -> Animation {
Animation::NONE
}
}
impl Describe for Spinner {
const KIND: &'static str = "spinner";
const DOC: &'static str =
"A turning arc, for when all there is to say is that something is happening.";
const GROUP: Group = Group::Indicator;
const ICON: &'static denise::icon::Icon = &super::icons::SPINNER;
const PROPERTIES: &'static [Property] = &[
Property::new(
"role",
PropertyKind::Enum(ROLES),
"Colour of the moving arc. The faint track behind it is derived from the same role.",
),
Property::new(
"thickness",
PropertyKind::Int { min: 1, max: 64 },
"Ring width in pixels. Derived from the node's size without it.",
)
.in_pixels(),
Property::new(
"period-ms",
PropertyKind::Int {
min: MIN_PERIOD_MS as i32,
max: 10_000,
},
"How long one full turn takes, in milliseconds.",
),
Property::new(
"frame-ms",
PropertyKind::Int { min: 1, max: 1_000 },
"This spinner's own sampling interval, overriding the tree's — a coarse one gives a ticking rather than a sweeping hand. Almost nothing should set it.",
),
];
fn get(&self, name: &str) -> Option<Value> {
Some(match name {
"role" => Value::role(self.role),
"thickness" => Value::Int(self.thickness?),
"period-ms" => Value::Int(i32::try_from(self.period_ms).unwrap_or(i32::MAX)),
"frame-ms" => Value::Int(i32::try_from(self.frame_ms?).unwrap_or(i32::MAX)),
_ => return None,
})
}
fn apply(&mut self, name: &str, value: Value) -> Result<(), Mismatch> {
match name {
"role" => self.role = value.as_role()?,
"thickness" => self.thickness = Some(value.as_int()?.max(1)),
"period-ms" => self.set_period_ms(value.as_millis()?),
"frame-ms" => self.set_frame_ms(Some(value.as_millis()?)),
_ => return Err(Mismatch::Unknown),
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
const FRAME_MS: u64 = Motion::DEFAULT_INTERVAL_MS;
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;
}
let laps = 2_000 * FRAME_MS / PERIOD_MS;
assert!(
wraps >= laps - laps / 10,
"{laps} laps expected from 2000 frames of {FRAME_MS} ms, 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 as u64 * FRAME_MS / 500) as i32);
assert_eq!(slow, (TURN as u64 * FRAME_MS / 4_000) as i32);
}
#[test]
fn an_impossibly_short_period_is_clamped_to_a_frame() {
for asked in [0, 1, 10, MIN_PERIOD_MS - 1] {
let spinner = Spinner::new().with_period_ms(asked);
assert_eq!(spinner.period_ms, MIN_PERIOD_MS, "asked for {asked}");
}
let mut spinner = Spinner::new();
spinner.set_period_ms(0);
assert_eq!(spinner.period_ms, MIN_PERIOD_MS);
}
#[test]
fn a_spinner_asks_for_the_trees_rate_unless_told_otherwise() {
let mut spinner = Spinner::new();
assert_eq!(tick(&mut spinner, 1_000).next, Wake::Animating);
let mut own = Spinner::new().with_frame_ms(100);
assert_eq!(tick(&mut own, 1_000).next, Wake::At(1_100));
let mut zero = Spinner::new().with_frame_ms(0);
assert_eq!(tick(&mut zero, 1_000).next, Wake::At(1_001));
own.set_frame_ms(None);
assert_eq!(tick(&mut own, 1_100).next, Wake::Animating);
}
#[test]
fn no_motion_stops_a_spinner_rather_than_landing_it() {
let mut spinner = Spinner::new();
tick(&mut spinner, 1_000);
let angle = spinner.angle();
assert_eq!(
Widget::<()>::snap(&mut spinner, 2_000),
Animation::NONE,
"a still ring wants nothing"
);
assert_eq!(spinner.angle(), angle, "and it did not jump on the way");
}
#[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_ne!(
animation.next,
Wake::Never,
"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") };
}
}