use std::time::Duration;
use frust::Theme;
use frust::authoring::{
BoxConstraints, BuildCtx, ChangeFlags, LayoutCtx, PaintCtx, PaintScene, View, Widget,
};
use frust::{AnimationController, Curve};
use kurbo::{Point, Size};
use peniko::Color;
const DOT_COUNT: usize = 3;
const DOT_DIAMETER: f64 = 8.0;
const DOT_GAP: f64 = 6.0;
const CYCLE_PERIOD: Duration = Duration::from_millis(1200);
const PHASE_OFFSET_SECONDS: f64 = 0.15;
const PHASE_OFFSET_FRACTION: f64 = PHASE_OFFSET_SECONDS / 1.2;
const MIN_OPACITY: f64 = 0.25;
const FALLBACK_COLOR: Color = Color::from_rgb8(0xff, 0xb6, 0x27);
fn dot_pulse(t: f64) -> f64 {
let rising = Curve::EaseInOut.interval(0.0, 0.5);
let falling = Curve::EaseInOut.interval(0.5, 1.0);
rising.transform(t) - falling.transform(t)
}
pub struct DotsLoaderView;
pub fn dots_loader() -> DotsLoaderView {
DotsLoaderView
}
#[allow(non_snake_case)]
pub fn DotsLoader() -> DotsLoaderView {
dots_loader()
}
impl<State: 'static> View<State> for DotsLoaderView {
type Element = DotsLoaderWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> DotsLoaderWidget {
let mut clock = AnimationController::new(CYCLE_PERIOD).with_curve(Curve::Linear);
clock.repeat();
DotsLoaderWidget { clock }
}
fn rebuild(
&self,
_prev: &Self,
_element: &mut DotsLoaderWidget,
_ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
ChangeFlags::NONE
}
}
pub struct DotsLoaderWidget {
clock: AnimationController,
}
impl DotsLoaderWidget {
fn resolve_color(theme: Option<&Theme>) -> Color {
match theme {
Some(theme) => theme.scheme().primary,
None => FALLBACK_COLOR,
}
}
fn local_phase(clock_phase: f64, i: usize) -> f64 {
(clock_phase + i as f64 * PHASE_OFFSET_FRACTION).rem_euclid(1.0)
}
}
fn intrinsic_size() -> Size {
let width = DOT_COUNT as f64 * DOT_DIAMETER + (DOT_COUNT as f64 - 1.0) * DOT_GAP;
Size::new(width, DOT_DIAMETER)
}
impl Widget for DotsLoaderWidget {
fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
bc.constrain(intrinsic_size())
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let theme = Theme::from_paint_ctx(ctx);
let reduce_motion = theme.map(|t| t.motion.reduce_motion).unwrap_or(false);
let color = Self::resolve_color(theme);
let origin = ctx.origin();
let size = ctx.size();
let cy = origin.y + size.height / 2.0;
let clock_phase = if reduce_motion {
None
} else {
self.clock.advance(ctx.frame_time());
Some(self.clock.value_clamped())
};
for i in 0..DOT_COUNT {
let opacity = match clock_phase {
Some(phase) => {
let local = Self::local_phase(phase, i);
MIN_OPACITY + (1.0 - MIN_OPACITY) * dot_pulse(local)
}
None => 1.0,
};
let cx = origin.x + i as f64 * (DOT_DIAMETER + DOT_GAP) + DOT_DIAMETER / 2.0;
let dot_origin = Point::new(cx - DOT_DIAMETER / 2.0, cy - DOT_DIAMETER / 2.0);
let dot_size = Size::new(DOT_DIAMETER, DOT_DIAMETER);
scene.fill_rounded_rect(
dot_origin,
dot_size,
DOT_DIAMETER / 2.0,
with_alpha(color, opacity as f32),
);
}
if !reduce_motion {
ctx.request_frame_paced();
}
}
}
fn with_alpha(color: Color, alpha: f32) -> Color {
let c = color.components;
Color::new([c[0], c[1], c[2], alpha])
}
#[cfg(test)]
mod tests {
use super::*;
use frust::FrameTime;
use frust::authoring::BuildCtx;
fn build() -> DotsLoaderWidget {
let view = dots_loader();
let mut counter = 0u64;
<DotsLoaderView as View<()>>::build(&view, &mut BuildCtx::new(&mut counter))
}
fn ft_secs(s: f64) -> FrameTime {
FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
}
#[derive(Default)]
struct RecordingScene {
rrects: Vec<(Point, Size, f64, Color)>,
}
impl PaintScene for RecordingScene {
fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
fn draw_text(&mut self, _o: Point, _t: &str) {}
fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
self.rrects.push((o, s, radius, color));
}
}
fn paint_at(w: &mut dyn Widget, size: Size, theme: Option<&Theme>) -> RecordingScene {
let mut ctx = match theme {
Some(t) => PaintCtx::new(Point::ZERO, size).with_theme(t),
None => PaintCtx::new(Point::ZERO, size),
};
let mut scene = RecordingScene::default();
w.paint(&mut ctx, &mut scene);
scene
}
#[test]
fn dot_pulse_peaks_at_the_half_cycle_and_troughs_at_the_edges() {
assert!(dot_pulse(0.0).abs() < 1e-9, "trough at the cycle start");
assert!(
(dot_pulse(0.5) - 1.0).abs() < 1e-9,
"peak at the half cycle"
);
assert!(
dot_pulse(1.0).abs() < 1e-9,
"trough at the cycle end (wraps to start)"
);
assert!(dot_pulse(0.25) < dot_pulse(0.4));
assert!(dot_pulse(0.6) > dot_pulse(0.9));
}
#[test]
fn per_dot_phase_offsets_are_staggered_and_wrap() {
let p0 = DotsLoaderWidget::local_phase(0.0, 0);
let p1 = DotsLoaderWidget::local_phase(0.0, 1);
let p2 = DotsLoaderWidget::local_phase(0.0, 2);
assert!((p0 - 0.0).abs() < 1e-9);
assert!((p1 - PHASE_OFFSET_FRACTION).abs() < 1e-9);
assert!((p2 - 2.0 * PHASE_OFFSET_FRACTION).abs() < 1e-9);
let near_end = 1.0 - PHASE_OFFSET_FRACTION / 2.0;
let wrapped = DotsLoaderWidget::local_phase(near_end, 1);
assert!(wrapped < PHASE_OFFSET_FRACTION, "must wrap, not exceed 1.0");
}
#[test]
fn clock_advances_linearly_and_repeats() {
let mut w = build();
assert!(w.clock.advance(ft_secs(0.0)));
assert!(w.clock.advance(ft_secs(0.3)));
assert!((w.clock.value_clamped() - 0.25).abs() < 1e-6);
assert!(w.clock.advance(ft_secs(1.3)));
assert!((w.clock.value_clamped() - (0.1 / 1.2)).abs() < 1e-6);
}
#[test]
fn paints_three_dots_and_requests_frames_unless_reduce_motion() {
let mut w = build();
let scene = paint_at(&mut w, intrinsic_size(), None);
assert_eq!(scene.rrects.len(), DOT_COUNT);
let mut ctx = PaintCtx::new(Point::ZERO, intrinsic_size());
let mut scene = RecordingScene::default();
w.paint(&mut ctx, &mut scene);
assert!(ctx.needs_frame());
assert!(
ctx.needs_frame_paced_only(),
"a dot pulse is a CosmeticLoop request — the frame gate must be able to pace it"
);
}
#[test]
fn reduce_motion_freezes_all_dots_at_full_opacity_and_stops_requesting_frames() {
let mut theme = crate::baseline();
theme.motion.reduce_motion = true;
let mut w = build();
let mut ctx = PaintCtx::new(Point::ZERO, intrinsic_size()).with_theme(&theme);
let mut scene = RecordingScene::default();
w.paint(&mut ctx, &mut scene);
assert!(!ctx.needs_frame());
assert_eq!(scene.rrects.len(), DOT_COUNT);
for (_, _, _, color) in &scene.rrects {
assert_eq!(
color.components[3], 1.0,
"static dots paint at full opacity"
);
}
}
#[test]
fn glyph_tokens_resolve_dark_and_light() {
let mut dark = crate::baseline();
dark.motion.reduce_motion = true;
let mut light = crate::baseline().with_brightness(frust::Brightness::Light);
light.motion.reduce_motion = true;
let mut w = build();
let scene = paint_at(&mut w, intrinsic_size(), Some(&dark));
assert_eq!(
scene.rrects[0].3.components[..3],
dark.scheme().primary.components[..3]
);
let mut w2 = build();
let scene2 = paint_at(&mut w2, intrinsic_size(), Some(&light));
assert_eq!(
scene2.rrects[0].3.components[..3],
light.scheme().primary.components[..3]
);
}
}