use egui::{
Color32, Painter, Pos2, Shape, Stroke,
epaint::{CircleShape, PathShape},
};
use std::f32::consts::TAU;
use std::time::Instant;
pub const PULSE_DURATION: f32 = 3.5;
pub const RIPPLE_DURATION: f32 = 3.5;
pub const COUNTDOWN_DURATION: f32 = 5.0;
pub const SCALE_IN_DURATION: f32 = 0.45;
pub const CROSSHAIR_DURATION: f32 = 0.6;
fn with_alpha(color: Color32, alpha: f32) -> Color32 {
Color32::from_rgba_unmultiplied(
color.r(),
color.g(),
color.b(),
(255.0 * alpha.clamp(0.0, 1.0)).round() as u8,
)
}
fn elapsed(initial_time: Instant) -> f32 {
Instant::now().duration_since(initial_time).as_secs_f32()
}
fn triangle_wave(time: f32, period: f32) -> f32 {
let phase = (time / period).rem_euclid(1.0);
1.0 - (2.0 * phase - 1.0).abs()
}
fn ease_out_back(x: f32) -> f32 {
const C1: f32 = 1.701_58;
const C3: f32 = C1 + 1.0;
let x1 = x - 1.0;
1.0 + C3 * x1 * x1 * x1 + C1 * x1 * x1
}
pub struct Animation {}
impl Animation {
pub fn pulse(
painter: &Painter,
center: Pos2,
zoom: f32,
initial_time: Instant,
color: Color32,
) -> bool {
let secs = elapsed(initial_time);
let radius = (4.00 + (40.00 * secs)) * zoom;
let transparency = (1.00 - (secs / PULSE_DURATION).abs()).max(0.0);
painter.add(Shape::Circle(CircleShape::filled(
center,
radius,
with_alpha(color, transparency),
)));
secs < PULSE_DURATION
}
pub fn ripple(
painter: &Painter,
center: Pos2,
zoom: f32,
initial_time: Instant,
color: Color32,
) -> bool {
const RINGS: usize = 3;
let secs = elapsed(initial_time);
let stagger = RIPPLE_DURATION / RINGS as f32;
let mut shapes = Vec::with_capacity(RINGS);
for ring in 0..RINGS {
let local = secs - ring as f32 * stagger;
if !(0.0..RIPPLE_DURATION).contains(&local) {
continue;
}
let progress = local / RIPPLE_DURATION;
shapes.push(Shape::Circle(CircleShape::stroke(
center,
(4.0 + 36.0 * progress) * zoom,
Stroke::new(2.0 * zoom, with_alpha(color, 1.0 - progress)),
)));
}
painter.extend(shapes);
secs < RIPPLE_DURATION
}
pub fn countdown_arc(
painter: &Painter,
center: Pos2,
zoom: f32,
initial_time: Instant,
color: Color32,
) -> bool {
const STEPS: usize = 48;
let secs = elapsed(initial_time);
let remaining = (1.0 - secs / COUNTDOWN_DURATION).clamp(0.0, 1.0);
let radius = 10.0 * zoom;
let count = (STEPS as f32 * remaining).round() as usize;
if count >= 1 {
let points = (0..=count)
.map(|i| {
let angle = TAU * (i as f32 / STEPS as f32) - TAU / 4.0;
Pos2::new(
center.x + radius * angle.cos(),
center.y + radius * angle.sin(),
)
})
.collect();
painter.add(Shape::Path(PathShape::line(
points,
Stroke::new(2.0 * zoom, with_alpha(color, 1.0)),
)));
}
secs < COUNTDOWN_DURATION
}
pub fn scale_in(
painter: &Painter,
center: Pos2,
zoom: f32,
initial_time: Instant,
color: Color32,
) -> bool {
let secs = elapsed(initial_time);
let progress = (secs / SCALE_IN_DURATION).clamp(0.0, 1.0);
let radius = 8.0 * zoom * ease_out_back(progress).max(0.0);
painter.add(Shape::Circle(CircleShape::filled(
center,
radius,
with_alpha(color, 1.0 - progress),
)));
secs < SCALE_IN_DURATION
}
pub fn crosshair(
painter: &Painter,
center: Pos2,
zoom: f32,
initial_time: Instant,
color: Color32,
) -> bool {
let secs = elapsed(initial_time);
let progress = (secs / CROSSHAIR_DURATION).clamp(0.0, 1.0);
let far = (30.0 - 18.0 * progress) * zoom;
let near = far - 8.0 * zoom;
let alpha = if progress < 0.66 {
1.0
} else {
1.0 - (progress - 0.66) / 0.34
};
let stroke = Stroke::new(2.0 * zoom, with_alpha(color, alpha));
let mut shapes = Vec::with_capacity(4);
for (dx, dy) in [(0.0, -1.0), (0.0, 1.0), (-1.0, 0.0), (1.0, 0.0)] {
shapes.push(Shape::line_segment(
[
Pos2::new(center.x + dx * far, center.y + dy * far),
Pos2::new(center.x + dx * near, center.y + dy * near),
],
stroke,
));
}
painter.extend(shapes);
secs < CROSSHAIR_DURATION
}
pub fn halo(painter: &Painter, center: Pos2, zoom: f32, time: f32, color: Color32) {
const PERIOD: f32 = 2.0;
let alpha = 0.30 + 0.45 * triangle_wave(time, PERIOD);
let radius = (9.0 * zoom).max(5.0);
painter.add(Shape::Circle(CircleShape::stroke(
center,
radius,
Stroke::new((2.0 * zoom).max(1.5), with_alpha(color, alpha)),
)));
}
pub fn blink(painter: &Painter, center: Pos2, zoom: f32, time: f32, color: Color32) {
const PERIOD: f32 = 2.55;
painter.add(Shape::Circle(CircleShape::stroke(
center,
4.0 * zoom,
Stroke::new(9.0 * zoom, with_alpha(color, triangle_wave(time, PERIOD))),
)));
}
pub fn orbit(painter: &Painter, center: Pos2, zoom: f32, time: f32, color: Color32) {
const PERIOD: f32 = 3.0;
let radius = (12.0 * zoom).max(7.0);
let angle = TAU * (time / PERIOD).rem_euclid(1.0);
let dot = Pos2::new(
center.x + radius * angle.cos(),
center.y + radius * angle.sin(),
);
painter.extend([
Shape::Circle(CircleShape::stroke(
center,
radius,
Stroke::new(1.0, with_alpha(color, 0.25)),
)),
Shape::Circle(CircleShape::filled(
dot,
(2.5 * zoom).max(2.0),
with_alpha(color, 1.0),
)),
]);
}
}
#[cfg(test)]
mod tests {
use super::*;
use egui::{Context, LayerId, Rect, Vec2};
use std::time::Duration;
fn headless_painter() -> Painter {
Painter::new(
Context::default(),
LayerId::background(),
Rect::from_min_size(Pos2::ZERO, Vec2::new(100.0, 100.0)),
)
}
#[allow(clippy::type_complexity)]
fn event_effects() -> Vec<(
&'static str,
fn(&Painter, Pos2, f32, Instant, Color32) -> bool,
f32,
)> {
vec![
("pulse", Animation::pulse, PULSE_DURATION),
("ripple", Animation::ripple, RIPPLE_DURATION),
(
"countdown_arc",
Animation::countdown_arc,
COUNTDOWN_DURATION,
),
("scale_in", Animation::scale_in, SCALE_IN_DURATION),
("crosshair", Animation::crosshair, CROSSHAIR_DURATION),
]
}
#[test]
fn event_effects_report_running_then_finished() {
let painter = headless_painter();
for (name, effect, duration) in event_effects() {
assert!(
effect(&painter, Pos2::ZERO, 1.0, Instant::now(), Color32::RED),
"{name} must report it is still running when it just started"
);
let long_past = Instant::now() - Duration::from_secs_f32(duration + 1.0);
assert!(
!effect(&painter, Pos2::ZERO, 1.0, long_past, Color32::RED),
"{name} must report it is finished once its duration has passed"
);
}
}
#[test]
fn every_event_effect_stays_under_the_orphan_sweep() {
for (name, _, duration) in event_effects() {
assert!(
duration < 10.0,
"{name} lasts {duration}s, which the 10s orphan sweep would truncate"
);
}
}
#[test]
fn persistent_effects_run_at_any_time() {
let painter = headless_painter();
for time in [0.0, 0.7, 1.3, 60.0] {
Animation::halo(&painter, Pos2::ZERO, 1.0, time, Color32::GREEN);
Animation::blink(&painter, Pos2::ZERO, 1.0, time, Color32::GREEN);
Animation::orbit(&painter, Pos2::ZERO, 1.0, time, Color32::GREEN);
}
}
#[test]
fn triangle_wave_goes_up_and_back_down() {
assert_eq!(triangle_wave(0.0, 2.0), 0.0);
assert_eq!(triangle_wave(1.0, 2.0), 1.0);
assert!(triangle_wave(2.0, 2.0).abs() < 1e-6);
assert!((triangle_wave(3.0, 2.0) - 1.0).abs() < 1e-6);
for step in 0..200 {
let v = triangle_wave(step as f32 * 0.05, 2.55);
assert!((0.0..=1.0).contains(&v), "{v} out of range");
}
}
#[test]
fn ease_out_back_overshoots_then_settles() {
assert_eq!(ease_out_back(0.0), 0.0);
assert!((ease_out_back(1.0) - 1.0).abs() < 1e-5);
let peak = (0..=100)
.map(|i| ease_out_back(i as f32 / 100.0))
.fold(f32::MIN, f32::max);
assert!(
peak > 1.0,
"ease_out_back should overshoot, peaked at {peak}"
);
}
#[test]
fn with_alpha_clamps_out_of_range_values() {
assert_eq!(with_alpha(Color32::RED, 2.0).a(), 255);
assert_eq!(with_alpha(Color32::RED, -1.0).a(), 0);
assert_eq!(with_alpha(Color32::RED, 1.0), Color32::RED);
}
}