1use egui::{Color32, Painter, Pos2, Rect, Stroke, Vec2, pos2, vec2};
20use serde::{Deserialize, Serialize};
21
22use crate::look::Motion;
23
24pub mod easing {
27 pub fn linear(t: f32) -> f32 {
29 t.clamp(0.0, 1.0)
30 }
31
32 pub fn ease_in_out_cubic(t: f32) -> f32 {
34 let t = t.clamp(0.0, 1.0);
35 if t < 0.5 {
36 4.0 * t * t * t
37 } else {
38 let f = -2.0 * t + 2.0;
39 1.0 - f * f * f / 2.0
40 }
41 }
42
43 pub fn ease_out_back(t: f32) -> f32 {
46 let t = t.clamp(0.0, 1.0);
47 const C1: f32 = 1.70158;
48 const C3: f32 = C1 + 1.0;
49 let f = t - 1.0;
50 1.0 + C3 * f * f * f + C1 * f * f
51 }
52
53 pub fn elastic(t: f32) -> f32 {
55 let t = t.clamp(0.0, 1.0);
56 if t == 0.0 || t == 1.0 {
57 return t;
58 }
59 const C4: f32 = std::f32::consts::TAU / 3.0;
60 2.0_f32.powf(-10.0 * t) * ((t * 10.0 - 0.75) * C4).sin() + 1.0
61 }
62
63 pub fn bounce(t: f32) -> f32 {
65 let t = t.clamp(0.0, 1.0);
66 const N1: f32 = 7.5625;
67 const D1: f32 = 2.75;
68 if t < 1.0 / D1 {
69 N1 * t * t
70 } else if t < 2.0 / D1 {
71 let t = t - 1.5 / D1;
72 N1 * t * t + 0.75
73 } else if t < 2.5 / D1 {
74 let t = t - 2.25 / D1;
75 N1 * t * t + 0.9375
76 } else {
77 let t = t - 2.625 / D1;
78 N1 * t * t + 0.984375
79 }
80 }
81}
82
83#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
88pub enum Curve {
89 Linear,
91 #[default]
93 EaseInOutCubic,
94 EaseOutBack,
96 Elastic,
98 Bounce,
100}
101
102impl Curve {
103 pub fn apply(self, t: f32) -> f32 {
105 match self {
106 Curve::Linear => easing::linear(t),
107 Curve::EaseInOutCubic => easing::ease_in_out_cubic(t),
108 Curve::EaseOutBack => easing::ease_out_back(t),
109 Curve::Elastic => easing::elastic(t),
110 Curve::Bounce => easing::bounce(t),
111 }
112 }
113}
114
115#[derive(Clone, Copy, Debug, PartialEq)]
132pub struct Tween {
133 pub from: f32,
135 pub to: f32,
137 pub duration: f32,
140 pub curve: Curve,
142}
143
144impl Tween {
145 pub fn new(from: f32, to: f32, duration: f32, curve: Curve) -> Self {
147 Self { from, to, duration, curve }
148 }
149
150 pub fn from_motion(motion: &Motion, from: f32, to: f32, curve: Curve) -> Self {
152 Self::new(from, to, motion.duration, curve)
153 }
154
155 pub fn from_motion_fast(motion: &Motion, from: f32, to: f32, curve: Curve) -> Self {
157 Self::new(from, to, motion.fast, curve)
158 }
159
160 pub fn value_at(&self, elapsed: f32) -> f32 {
163 if self.duration <= 0.0 {
164 return self.to;
165 }
166 let t = (elapsed / self.duration).clamp(0.0, 1.0);
167 self.from + (self.to - self.from) * self.curve.apply(t)
168 }
169
170 pub fn progress_at(&self, elapsed: f32) -> f32 {
173 if self.duration <= 0.0 {
174 return 1.0;
175 }
176 self.curve.apply((elapsed / self.duration).clamp(0.0, 1.0))
177 }
178
179 pub fn is_done(&self, elapsed: f32) -> bool {
181 self.duration <= 0.0 || elapsed >= self.duration
182 }
183}
184
185pub fn tween_rect(from: Rect, to: Rect, eased_t: f32) -> Rect {
190 let t = eased_t.clamp(0.0, 1.0);
191 let lerp = |a: f32, b: f32| a + (b - a) * t;
192 Rect::from_min_max(
193 pos2(lerp(from.min.x, to.min.x), lerp(from.min.y, to.min.y)),
194 pos2(lerp(from.max.x, to.max.x), lerp(from.max.y, to.max.y)),
195 )
196}
197
198#[derive(Clone, Debug, Default)]
215pub struct FadeTrack {
216 fades: std::collections::HashMap<u64, FadeState>,
217}
218
219#[derive(Clone, Copy, Debug)]
220struct FadeState {
221 factor: f32,
222 lit: bool,
223}
224
225impl FadeTrack {
226 pub fn begin(&mut self) {
229 for f in self.fades.values_mut() {
230 f.lit = false;
231 }
232 }
233
234 pub fn lit(&mut self, key: u64) {
237 self.fades.entry(key).or_insert(FadeState { factor: 0.0, lit: false }).lit = true;
238 }
239
240 pub fn factor(&self, key: u64) -> f32 {
243 self.fades.get(&key).map(|f| f.factor).unwrap_or(0.0)
244 }
245
246 pub fn active(&self) -> usize {
249 self.fades.len()
250 }
251
252 pub fn is_animating(&self) -> bool {
255 self.fades.values().any(|f| f.factor > 1e-3 && f.factor < 1.0 - 1e-3)
256 }
257
258 pub fn advance(&mut self, dt: f32, duration: f32) -> bool {
263 let step = if duration <= 0.0 { 1.0 } else { (dt.max(0.0) / duration).clamp(0.0, 1.0) };
264 let mut animating = false;
265 self.fades.retain(|_, f| {
266 let target = if f.lit { 1.0 } else { 0.0 };
267 let dist = target - f.factor;
268 if dist.abs() <= step {
269 f.factor = target;
270 } else {
271 f.factor += step * dist.signum();
272 animating = true;
273 }
274 f.lit || f.factor > 1e-3
276 });
277 animating
278 }
279
280 pub fn key(id: impl std::hash::Hash) -> u64 {
284 use std::hash::Hasher as _;
285 let mut h = std::collections::hash_map::DefaultHasher::new();
286 id.hash(&mut h);
287 h.finish()
288 }
289}
290
291fn lerp_color(a: Color32, b: Color32, t: f32) -> Color32 {
293 let t = t.clamp(0.0, 1.0);
294 let l = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * t).round() as u8;
295 Color32::from_rgba_unmultiplied(l(a.r(), b.r()), l(a.g(), b.g()), l(a.b(), b.b()), l(a.a(), b.a()))
296}
297
298pub fn glow_rect(painter: &Painter, rect: Rect, color: Color32, intensity: f32, layers: u32) {
302 let intensity = intensity.clamp(0.0, 1.0);
303 let layers = layers.max(1);
304 for i in 0..layers {
305 let f = i as f32 / layers as f32; let grow = 1.0 + f * 7.0;
307 let alpha = ((1.0 - f) * (1.0 - f) * 90.0 * intensity) as u8;
308 if alpha == 0 {
309 continue;
310 }
311 let c = Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), alpha);
312 painter.rect_stroke(
313 rect.expand(grow),
314 4.0 + grow,
315 Stroke::new(1.5 + f * 2.0, c),
316 egui::StrokeKind::Outside,
317 );
318 }
319}
320
321#[derive(Clone, Copy, Debug, PartialEq)]
341pub struct RevealHighlight {
342 pub color: Color32,
344 pub radius: f32,
346 pub intensity: f32,
348 pub layers: u32,
350}
351
352impl RevealHighlight {
353 pub fn new(color: Color32) -> Self {
355 Self { color, radius: 120.0, intensity: 0.8, layers: 4 }
356 }
357
358 pub fn with_radius(mut self, radius: f32) -> Self {
360 self.radius = radius.max(1.0);
361 self
362 }
363
364 pub fn with_intensity(mut self, intensity: f32) -> Self {
366 self.intensity = intensity.clamp(0.0, 1.0);
367 self
368 }
369
370 pub fn proximity(&self, rect: Rect, pointer: Option<Pos2>) -> f32 {
374 let Some(p) = pointer else { return 0.0 };
375 let nx = p.x.clamp(rect.left(), rect.right());
377 let ny = p.y.clamp(rect.top(), rect.bottom());
378 let dist = (p - pos2(nx, ny)).length();
379 if dist >= self.radius {
380 return 0.0;
381 }
382 let t = 1.0 - dist / self.radius; t * t }
385
386 pub fn paint(&self, painter: &Painter, rect: Rect, pointer: Option<Pos2>, corner_radius: f32) {
390 let lit = self.proximity(rect, pointer) * self.intensity;
391 if lit <= 0.0 {
392 return;
393 }
394 let layers = self.layers.max(1);
395 for i in 0..layers {
396 let f = i as f32 / layers as f32; let grow = f * 4.0;
398 let alpha = ((1.0 - f) * 150.0 * lit) as u8;
399 if alpha == 0 {
400 continue;
401 }
402 let c = Color32::from_rgba_unmultiplied(self.color.r(), self.color.g(), self.color.b(), alpha);
403 painter.rect_stroke(
404 rect.expand(grow),
405 corner_radius + grow,
406 Stroke::new(1.0 + f, c),
407 egui::StrokeKind::Outside,
408 );
409 }
410 }
411
412 pub fn paint_in(&self, ui: &egui::Ui, rect: Rect, corner_radius: f32, enabled: bool) {
418 if !enabled || !crate::look::effects_policy(ui).allows_decorative_motion() {
419 return;
420 }
421 let pointer = ui.input(|i| i.pointer.hover_pos());
422 self.paint(ui.painter(), rect, pointer, corner_radius);
423 }
424}
425
426#[allow(clippy::too_many_arguments)]
429pub fn glow_text(
430 painter: &Painter,
431 pos: Pos2,
432 anchor: egui::Align2,
433 text: &str,
434 font: egui::FontId,
435 text_color: Color32,
436 glow: Color32,
437 intensity: f32,
438) {
439 let intensity = intensity.clamp(0.0, 1.0);
440 let halo = Color32::from_rgba_unmultiplied(glow.r(), glow.g(), glow.b(), (70.0 * intensity) as u8);
441 for r in [3.0_f32, 2.0, 1.0] {
442 for k in 0..8 {
443 let a = std::f32::consts::TAU * k as f32 / 8.0;
444 let off = vec2(a.cos(), a.sin()) * r;
445 painter.text(pos + off, anchor, text, font.clone(), halo);
446 }
447 }
448 painter.text(pos, anchor, text, font, text_color);
449}
450
451pub fn shimmer(painter: &Painter, rect: Rect, color: Color32, t: f32) {
455 let bars = 24;
456 let band = 0.18; let center = t.rem_euclid(1.0) * (1.0 + 2.0 * band) - band;
458 for i in 0..bars {
459 let x = (i as f32 + 0.5) / bars as f32; let d = (x - center).abs() / band;
461 if d >= 1.0 {
462 continue;
463 }
464 let g = (1.0 - d) * (1.0 - d); let bright = lerp_color(color, Color32::WHITE, g * 0.6);
468 let c = Color32::from_rgba_unmultiplied(bright.r(), bright.g(), bright.b(), (g * 130.0) as u8);
469 let bx0 = rect.left() + x * rect.width();
470 let bw = rect.width() / bars as f32 + 1.0;
471 let shear = (x - 0.5) * rect.height() * 0.25;
473 let seg = Rect::from_min_max(pos2(bx0, rect.top() + shear), pos2(bx0 + bw, rect.bottom() + shear))
474 .intersect(rect);
475 painter.rect_filled(seg, 0.0, c);
476 }
477}
478
479#[derive(Clone, Copy)]
481struct Particle {
482 pos: Pos2,
483 vel: Vec2,
484 age: f32,
486}
487
488#[derive(Clone)]
493pub struct ParticleBurst {
494 particles: Vec<Particle>,
495 color: Color32,
496 gravity: f32,
498 lifetime: f32,
500 elapsed: f32,
501}
502
503impl ParticleBurst {
504 pub fn new(origin: Pos2, count: usize, color: Color32, seed: u64) -> Self {
507 let mut h = seed ^ 0x9E37_79B9_7F4A_7C15;
508 let mut rng = || {
509 h = h.wrapping_add(0x9E37_79B9_7F4A_7C15);
511 let mut z = h;
512 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
513 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
514 ((z ^ (z >> 31)) as f64 / u64::MAX as f64) as f32
515 };
516 let particles = (0..count)
517 .map(|i| {
518 let a = std::f32::consts::TAU * i as f32 / count.max(1) as f32;
519 let speed = 90.0 + rng() * 140.0;
520 let up = 0.6 + rng() * 0.4; Particle { pos: origin, vel: vec2(a.cos() * speed, a.sin() * speed - 120.0 * up), age: 0.0 }
522 })
523 .collect();
524 Self { particles, color, gravity: 520.0, lifetime: 1.1, elapsed: 0.0 }
525 }
526
527 pub fn update(&mut self, dt: f32) {
529 let dt = dt.max(0.0);
530 self.elapsed += dt;
531 for p in &mut self.particles {
532 p.vel.y += self.gravity * dt;
533 p.pos += p.vel * dt;
534 p.age += dt;
535 }
536 }
537
538 pub fn finished(&self) -> bool {
541 self.elapsed >= self.lifetime
542 }
543
544 pub fn paint(&self, painter: &Painter) {
546 for p in &self.particles {
547 let life = (1.0 - p.age / self.lifetime).clamp(0.0, 1.0);
548 if life <= 0.0 {
549 continue;
550 }
551 let a = (life * 220.0) as u8;
552 let c = Color32::from_rgba_unmultiplied(self.color.r(), self.color.g(), self.color.b(), a);
553 painter.circle_filled(p.pos, 1.0 + life * 2.5, c);
554 }
555 }
556}
557
558fn bezier2(p0: Pos2, p1: Pos2, p2: Pos2, t: f32) -> Pos2 {
560 let u = 1.0 - t;
561 let x = u * u * p0.x + 2.0 * u * t * p1.x + t * t * p2.x;
562 let y = u * u * p0.y + 2.0 * u * t * p1.y + t * t * p2.y;
563 pos2(x, y)
564}
565
566pub const RAVEN_FLIGHT_SECS: f32 = 1.4;
568
569#[derive(Clone)]
581pub struct RavenSprite {
582 start: Pos2,
583 target: Pos2,
584 launch_time: Option<f64>,
586 current: Pos2,
588 perched: bool,
590 elapsed: f32,
592 color: Color32,
593 facing: f32,
595 scale: f32,
596}
597
598impl Default for RavenSprite {
599 fn default() -> Self {
600 Self::new()
601 }
602}
603
604impl RavenSprite {
605 pub fn new() -> Self {
607 Self {
608 start: pos2(-40.0, -40.0),
609 target: pos2(0.0, 0.0),
610 launch_time: None,
611 current: pos2(-40.0, -40.0),
612 perched: false,
613 elapsed: 0.0,
614 color: Color32::from_rgb(18, 18, 22),
615 facing: 1.0,
616 scale: 1.0,
617 }
618 }
619
620 pub fn from(mut self, start: Pos2) -> Self {
622 self.start = start;
623 self.current = start;
624 self
625 }
626
627 pub fn color(mut self, color: Color32) -> Self {
630 self.color = color;
631 self
632 }
633
634 pub fn scale(mut self, scale: f32) -> Self {
636 self.scale = scale.max(0.1);
637 self
638 }
639
640 pub fn fly_to(mut self, target: Rect) -> Self {
643 self.target = pos2(target.center().x, target.top());
645 self.facing = if self.target.x >= self.start.x { 1.0 } else { -1.0 };
646 self.launch_time = None;
647 self.perched = false;
648 self
649 }
650
651 fn control(&self) -> Pos2 {
654 let mid = pos2((self.start.x + self.target.x) * 0.5, (self.start.y + self.target.y) * 0.5);
655 let span = (self.target - self.start).length().max(1.0);
656 pos2(mid.x, mid.y - span * 0.45) }
658
659 pub fn pos_at(&self, elapsed: f32) -> Pos2 {
664 if elapsed >= RAVEN_FLIGHT_SECS {
665 let bob = ((elapsed - RAVEN_FLIGHT_SECS) * std::f32::consts::TAU * 0.6).sin() * 1.5;
667 return pos2(self.target.x, self.target.y + bob);
668 }
669 let lin = (elapsed / RAVEN_FLIGHT_SECS).clamp(0.0, 1.0);
670 let t = easing::ease_out_back(lin);
671 bezier2(self.start, self.control(), self.target, t)
672 }
673
674 pub fn is_perched(&self) -> bool {
676 self.perched
677 }
678
679 pub fn pos(&self) -> Pos2 {
681 self.current
682 }
683
684 pub fn update(&mut self, ctx: &egui::Context) {
688 let now = ctx.input(|i| i.time);
689 let launch = *self.launch_time.get_or_insert(now);
690 let elapsed = (now - launch) as f32;
691 self.advance(elapsed);
692 if !self.perched {
693 ctx.request_repaint();
694 }
695 }
696
697 pub fn advance(&mut self, elapsed: f32) {
700 self.elapsed = elapsed;
701 self.current = self.pos_at(elapsed);
702 self.perched = elapsed >= RAVEN_FLIGHT_SECS;
703 }
704
705 pub fn paint(&self, painter: &Painter) {
709 let c = self.current;
710 let s = self.scale;
711 let f = self.facing;
712 let body = self.color;
713 let stroke = Stroke::new(1.0 * s, body);
714
715 let flap = if self.perched {
717 0.15
718 } else {
719 (self.elapsed * std::f32::consts::TAU * 5.0).sin() * 0.5 + 0.5
721 };
722 let wing_lift = (flap - 0.5) * 9.0 * s; painter.circle_filled(c, 5.0 * s, body);
726 painter.circle_filled(c + vec2(-3.5 * s * f, 1.0 * s), 3.5 * s, body);
727
728 let tail_root = c + vec2(-5.0 * s * f, 0.5 * s);
730 painter.add(egui::Shape::convex_polygon(
731 vec![
732 tail_root,
733 tail_root + vec2(-7.0 * s * f, -2.5 * s),
734 tail_root + vec2(-7.5 * s * f, 1.0 * s),
735 tail_root + vec2(-6.0 * s * f, 3.0 * s),
736 ],
737 body,
738 stroke,
739 ));
740
741 let shoulder = c + vec2(-s * f, -1.5 * s);
743 let tip_far = shoulder + vec2(-9.0 * s * f, -wing_lift - 2.0 * s);
744 let tip_near = shoulder + vec2(-4.0 * s * f, -wing_lift * 0.5 + 4.0 * s);
745 painter.add(egui::Shape::convex_polygon(
746 vec![shoulder, tip_far, tip_near],
747 body,
748 stroke,
749 ));
750
751 let head = c + vec2(4.5 * s * f, -2.5 * s);
753 painter.circle_filled(head, 3.0 * s, body);
754 let beak_color = Color32::from_rgb(40, 30, 18); painter.add(egui::Shape::convex_polygon(
756 vec![
757 head + vec2(2.5 * s * f, -0.5 * s),
758 head + vec2(6.5 * s * f, 0.5 * s),
759 head + vec2(2.5 * s * f, 1.5 * s),
760 ],
761 beak_color,
762 Stroke::NONE,
763 ));
764 painter.circle_filled(head + vec2(1.2 * s * f, -0.8 * s), 0.8 * s, Color32::from_rgb(230, 220, 210));
766 }
767}
768
769#[derive(Clone, Copy)]
773struct Icicle {
774 x: f32,
776 len: f32,
778 half_w: f32,
780 phase: f32,
782 rate: f32,
784}
785
786#[derive(Clone)]
804pub struct IceDrip {
805 icicles: Vec<Icicle>,
806 ice: Color32,
808 glow: Color32,
810 elapsed: f32,
813 period: f32,
815 fall_frac: f32,
817 enabled: bool,
819}
820
821impl IceDrip {
822 pub fn new(count: usize, seed: u64) -> Self {
826 let mut h = seed ^ 0x51ED_2701_A17F_C3B9;
827 let mut rng = || {
828 h = h.wrapping_add(0x9E37_79B9_7F4A_7C15);
829 let mut z = h;
830 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
831 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
832 ((z ^ (z >> 31)) as f64 / u64::MAX as f64) as f32
833 };
834 let n = count.max(1);
835 let icicles = (0..n)
836 .map(|i| {
837 let base = (i as f32 + 0.5) / n as f32;
839 let x = (base + (rng() - 0.5) * 0.6 / n as f32).clamp(0.0, 1.0);
840 Icicle {
841 x,
842 len: 8.0 + rng() * 16.0,
843 half_w: 3.0 + rng() * 3.0,
844 phase: rng(),
845 rate: 0.7 + rng() * 0.6,
846 }
847 })
848 .collect();
849 Self {
850 icicles,
851 ice: Color32::from_rgb(206, 232, 248), glow: Color32::from_rgb(170, 214, 240), elapsed: 0.0,
854 period: 2.6,
855 fall_frac: 0.85,
856 enabled: false,
857 }
858 }
859
860 pub fn enabled(mut self, on: bool) -> Self {
862 self.enabled = on;
863 self
864 }
865
866 pub fn colors(mut self, ice: Color32, glow: Color32) -> Self {
868 self.ice = ice;
869 self.glow = glow;
870 self
871 }
872
873 pub fn with_period(mut self, secs: f32) -> Self {
875 self.period = secs.max(0.1);
876 self
877 }
878
879 pub fn at_clock(mut self, secs: f32) -> Self {
882 self.elapsed = secs.max(0.0);
883 self
884 }
885
886 pub fn set_clock(&mut self, secs: f32) {
888 self.elapsed = secs.max(0.0);
889 }
890
891 pub fn update(&mut self, dt: f32) {
893 self.elapsed += dt.max(0.0);
894 }
895
896 pub fn is_enabled(&self) -> bool {
898 self.enabled
899 }
900
901 fn cycle(&self, ic: &Icicle) -> f32 {
903 ((self.elapsed / self.period) * ic.rate + ic.phase).rem_euclid(1.0)
904 }
905
906 fn droplet_fall_norm(c: f32) -> f32 {
910 let c = c.clamp(0.0, 1.0);
911 c * c
912 }
913
914 fn droplet_alpha(c: f32) -> f32 {
917 let c = c.clamp(0.0, 1.0);
918 if c < 0.08 {
919 c / 0.08
920 } else if c < 0.75 {
921 1.0
922 } else {
923 (1.0 - (c - 0.75) / 0.25).max(0.0)
924 }
925 }
926
927 fn frost_phase(&self) -> f32 {
929 (self.elapsed * 0.2).rem_euclid(1.0)
930 }
931
932 pub fn paint(&self, painter: &Painter, rect: Rect) {
935 if !self.enabled || rect.width() <= 0.0 || rect.height() <= 0.0 {
936 return;
937 }
938 let band = Rect::from_min_max(rect.left_top(), pos2(rect.right(), rect.top() + 14.0));
940 shimmer(painter, band, self.glow, self.frost_phase());
941
942 for ic in &self.icicles {
943 let x = rect.left() + ic.x * rect.width();
944 let tip = pos2(x, rect.top() + ic.len);
945 let base_l = pos2(x - ic.half_w, rect.top());
947 let base_r = pos2(x + ic.half_w, rect.top());
948 let body = Color32::from_rgba_unmultiplied(self.ice.r(), self.ice.g(), self.ice.b(), 150);
949 painter.add(egui::Shape::convex_polygon(
950 vec![base_l, base_r, tip],
951 body,
952 Stroke::new(1.0, self.glow),
953 ));
954 painter.line_segment(
956 [pos2(x, rect.top()), tip],
957 Stroke::new(1.0, Color32::from_rgba_unmultiplied(255, 255, 255, 90)),
958 );
959
960 let c = self.cycle(ic);
962 let a = Self::droplet_alpha(c);
963 if a > 0.0 {
964 let y = tip.y + Self::droplet_fall_norm(c) * self.fall_frac * rect.height();
965 let col = Color32::from_rgba_unmultiplied(self.glow.r(), self.glow.g(), self.glow.b(), (a * 220.0) as u8);
966 painter.circle_filled(pos2(x, y), 2.2, col);
968 painter.circle_filled(pos2(x, y - 2.6), 1.1, col);
969 }
970 }
971 }
972
973 pub fn paint_gated(&self, ui: &egui::Ui, rect: Rect) {
978 if crate::look::effects_policy(ui).allows_decorative_motion() {
979 self.paint(ui.painter(), rect);
980 }
981 }
982
983 pub fn state_json(&self) -> serde_json::Value {
987 let droplets: Vec<serde_json::Value> = if self.enabled {
988 self.icicles
989 .iter()
990 .filter_map(|ic| {
991 let c = self.cycle(ic);
992 let a = Self::droplet_alpha(c);
993 (a > 0.0).then(|| {
994 serde_json::json!({
995 "x": ic.x,
996 "y_norm": Self::droplet_fall_norm(c),
997 "alpha": a,
998 })
999 })
1000 })
1001 .collect()
1002 } else {
1003 Vec::new()
1004 };
1005 serde_json::json!({
1006 "enabled": self.enabled,
1007 "icicles": self.icicles.len(),
1008 "active_droplets": droplets.len(),
1009 "frost_phase": self.frost_phase(),
1010 "elapsed_s": self.elapsed,
1011 "droplets": droplets,
1012 })
1013 }
1014}
1015
1016#[cfg(test)]
1017mod tests {
1018 use super::*;
1019
1020 type NamedEasing = (&'static str, fn(f32) -> f32);
1022
1023 fn approx(a: f32, b: f32, eps: f32) -> bool {
1024 (a - b).abs() <= eps
1025 }
1026
1027 #[test]
1028 fn easing_fns_hit_their_endpoints() {
1029 let fns: [NamedEasing; 5] = [
1030 ("linear", easing::linear),
1031 ("cubic", easing::ease_in_out_cubic),
1032 ("back", easing::ease_out_back),
1033 ("elastic", easing::elastic),
1034 ("bounce", easing::bounce),
1035 ];
1036 for (name, f) in fns {
1037 assert!(approx(f(0.0), 0.0, 1e-5), "{name}(0) should be 0, got {}", f(0.0));
1038 assert!(approx(f(1.0), 1.0, 1e-5), "{name}(1) should be 1, got {}", f(1.0));
1039 assert!(approx(f(-1.0), 0.0, 1e-5), "{name}(-1) clamps to 0");
1041 assert!(approx(f(2.0), 1.0, 1e-5), "{name}(2) clamps to 1");
1042 }
1043 }
1044
1045 #[test]
1046 fn tween_hits_endpoints_and_clamps() {
1047 let tw = Tween::new(10.0, 50.0, 0.2, Curve::EaseInOutCubic);
1048 assert!(approx(tw.value_at(0.0), 10.0, 1e-4), "start = from");
1049 assert!(approx(tw.value_at(0.2), 50.0, 1e-4), "end = to");
1050 assert!(approx(tw.value_at(-1.0), 10.0, 1e-4), "before start clamps to from");
1052 assert!(approx(tw.value_at(99.0), 50.0, 1e-4), "after end clamps to to");
1053 assert!(!tw.is_done(0.1) && tw.is_done(0.2), "done at/after duration");
1054 assert!(approx(tw.progress_at(0.0), 0.0, 1e-4) && approx(tw.progress_at(0.2), 1.0, 1e-4));
1056 }
1057
1058 #[test]
1059 fn tween_zero_duration_snaps_to_target() {
1060 let tw = Tween::new(0.0, 1.0, 0.0, Curve::EaseInOutCubic);
1062 assert_eq!(tw.value_at(0.0), 1.0);
1063 assert_eq!(tw.progress_at(0.0), 1.0);
1064 assert!(tw.is_done(0.0));
1065 }
1066
1067 #[test]
1068 fn tween_from_motion_uses_theme_durations() {
1069 let m = crate::look::Motion::default(); let slow = Tween::from_motion(&m, 0.0, 1.0, Curve::Linear);
1071 let fast = Tween::from_motion_fast(&m, 0.0, 1.0, Curve::Linear);
1072 assert!(approx(slow.duration, m.duration, 1e-6));
1073 assert!(approx(fast.duration, m.fast, 1e-6));
1074 assert!(approx(slow.value_at(m.duration / 2.0), 0.5, 1e-4));
1076 assert!(approx(fast.value_at(m.fast / 2.0), 0.5, 1e-4));
1077 }
1078
1079 #[test]
1080 fn tween_rect_lerps_corners_for_slide_over() {
1081 let off = Rect::from_min_max(pos2(100.0, 0.0), pos2(140.0, 50.0));
1082 let on = Rect::from_min_max(pos2(0.0, 0.0), pos2(40.0, 50.0));
1083 assert_eq!(tween_rect(off, on, 0.0), off, "t=0 → off-screen rect");
1084 assert_eq!(tween_rect(off, on, 1.0), on, "t=1 → on-screen rect");
1085 let mid = tween_rect(off, on, 0.5);
1086 assert!(approx(mid.min.x, 50.0, 1e-4), "half-slid x");
1087 }
1088
1089 #[test]
1090 fn fade_track_fades_in_then_out_and_drops_settled_keys() {
1091 let k = FadeTrack::key("row-7");
1092 let mut ft = FadeTrack::default();
1093 for _ in 0..12 {
1095 ft.begin();
1096 ft.lit(k);
1097 ft.advance(1.0 / 60.0, 0.10);
1098 }
1099 assert!(approx(ft.factor(k), 1.0, 1e-3), "lit key reaches 1, got {}", ft.factor(k));
1100 let mut animating = true;
1102 for _ in 0..40 {
1103 ft.begin();
1104 animating = ft.advance(1.0 / 60.0, 0.10);
1105 }
1106 assert!(!animating, "settles (no repaint) once faded out");
1107 assert_eq!(ft.factor(k), 0.0, "faded-out key reads 0 (dropped)");
1108 }
1109
1110 #[test]
1111 fn fade_track_zero_duration_snaps() {
1112 let k = FadeTrack::key(3u64);
1114 let mut ft = FadeTrack::default();
1115 ft.begin();
1116 ft.lit(k);
1117 let animating = ft.advance(1.0 / 60.0, 0.0);
1118 assert_eq!(ft.factor(k), 1.0, "zero-duration snaps lit → 1");
1119 assert!(!animating, "snap is not 'animating'");
1120 }
1121
1122 #[test]
1123 fn fade_track_is_deterministic() {
1124 let k = FadeTrack::key("r");
1125 let mut a = FadeTrack::default();
1126 let mut b = FadeTrack::default();
1127 for _ in 0..5 {
1128 a.begin();
1129 a.lit(k);
1130 a.advance(1.0 / 60.0, 0.18);
1131 b.begin();
1132 b.lit(k);
1133 b.advance(1.0 / 60.0, 0.18);
1134 }
1135 assert!(approx(a.factor(k), b.factor(k), 1e-9), "same inputs → same factor (FC-7)");
1136 }
1137
1138 #[test]
1139 fn ease_out_back_overshoots_before_settling() {
1140 let peak = (60..100).map(|i| easing::ease_out_back(i as f32 / 100.0)).fold(0.0_f32, f32::max);
1142 assert!(peak > 1.0, "ease_out_back should overshoot, peak={peak}");
1143 }
1144
1145 #[test]
1146 fn raven_starts_at_launch_and_converges_onto_target_rect() {
1147 let target = Rect::from_min_size(pos2(300.0, 200.0), vec2(180.0, 24.0));
1148 let mut raven = RavenSprite::new().from(pos2(-40.0, -40.0)).fly_to(target);
1149
1150 raven.advance(0.0);
1152 assert!(!raven.is_perched());
1153 assert!(approx(raven.pos().x, -40.0, 0.5) && approx(raven.pos().y, -40.0, 0.5), "starts at launch");
1154
1155 raven.advance(RAVEN_FLIGHT_SECS * 0.5);
1158 assert!(!raven.is_perched());
1159
1160 raven.advance(RAVEN_FLIGHT_SECS);
1163 assert!(raven.is_perched(), "perched after flight duration");
1164 let perch = pos2(target.center().x, target.top());
1165 let d = (raven.pos() - perch).length();
1166 assert!(d <= 2.0, "raven converges onto the perch (dist {d} px)");
1167
1168 for k in 1..20 {
1170 raven.advance(RAVEN_FLIGHT_SECS + k as f32 * 0.05);
1171 assert!(approx(raven.pos().x, perch.x, 0.01), "x stays centred on perch");
1172 assert!(approx(raven.pos().y, perch.y, 2.0), "y stays within bob of perch");
1173 }
1174 }
1175
1176 #[test]
1177 fn particle_burst_falls_and_finishes() {
1178 let mut b = ParticleBurst::new(pos2(100.0, 100.0), 16, Color32::WHITE, 42);
1179 assert!(!b.finished());
1180 for _ in 0..120 {
1181 b.update(1.0 / 60.0);
1182 }
1183 assert!(b.finished(), "burst should expire after its lifetime");
1184 let mut a = ParticleBurst::new(pos2(0.0, 0.0), 8, Color32::WHITE, 7);
1186 let mut c = ParticleBurst::new(pos2(0.0, 0.0), 8, Color32::WHITE, 7);
1187 a.update(0.1);
1188 c.update(0.1);
1189 assert_eq!(a.particles[0].pos, c.particles[0].pos, "same seed → same motion");
1190 }
1191
1192 #[test]
1193 fn ice_drip_is_off_until_enabled() {
1194 let off = IceDrip::new(12, 3).at_clock(2.0);
1196 let s = off.state_json();
1197 assert_eq!(s["enabled"], false);
1198 assert_eq!(s["active_droplets"], 0, "disabled ⇒ no droplets: {s}");
1199 assert_eq!(s["icicles"], 12, "icicle count is still reported: {s}");
1200
1201 let on = IceDrip::new(12, 3).enabled(true).at_clock(2.0);
1203 let s = on.state_json();
1204 assert_eq!(s["enabled"], true);
1205 assert!(s["active_droplets"].as_u64().unwrap() > 0, "enabled ⇒ live droplets: {s}");
1206 }
1207
1208 #[test]
1209 fn ice_drip_is_deterministic_given_seed_and_clock() {
1210 let a = IceDrip::new(10, 7).enabled(true).at_clock(2.0);
1212 let b = IceDrip::new(10, 7).enabled(true).at_clock(2.0);
1213 assert_eq!(a.state_json(), b.state_json(), "same (seed,clock) ⇒ identical drip");
1214 let later = IceDrip::new(10, 7).enabled(true).at_clock(2.4);
1216 assert_ne!(a.state_json(), later.state_json(), "advancing the clock moves the drip");
1217 }
1218
1219 #[test]
1220 fn reveal_highlight_proximity_is_a_cursor_pool() {
1221 let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 40.0));
1222 let r = RevealHighlight::new(Color32::from_rgb(80, 160, 255)).with_radius(100.0);
1223 assert!(approx(r.proximity(rect, Some(pos2(50.0, 20.0))), 1.0, 1e-6), "inside → 1");
1225 assert_eq!(r.proximity(rect, None), 0.0, "no pointer → 0");
1227 assert_eq!(r.proximity(rect, Some(pos2(300.0, 20.0))), 0.0, "far (>radius) → 0");
1228 let near = r.proximity(rect, Some(pos2(120.0, 20.0))); let mid = r.proximity(rect, Some(pos2(160.0, 20.0))); assert!(near > mid && mid > 0.0, "reveal glow decays with distance: {near} > {mid} > 0");
1232 assert_eq!(r.proximity(rect, Some(pos2(130.0, 10.0))), r.proximity(rect, Some(pos2(130.0, 10.0))));
1234 }
1235
1236 #[test]
1237 fn ice_drip_droplets_fall_under_gravity() {
1238 assert!((IceDrip::droplet_fall_norm(0.0)).abs() < 1e-6);
1241 assert!((IceDrip::droplet_fall_norm(1.0) - 1.0).abs() < 1e-6);
1242 let mut prev = 0.0;
1243 let mut last_step = 0.0;
1244 for i in 1..=10 {
1245 let c = i as f32 / 10.0;
1246 let y = IceDrip::droplet_fall_norm(c);
1247 assert!(y >= prev, "fall is monotonic downward at c={c}: {y} < {prev}");
1248 let step = y - prev;
1249 assert!(step >= last_step - 1e-6, "fall accelerates (gravity) at c={c}");
1250 last_step = step;
1251 prev = y;
1252 }
1253 assert!(IceDrip::droplet_alpha(0.0) < IceDrip::droplet_alpha(0.5));
1255 assert!((IceDrip::droplet_alpha(0.5) - 1.0).abs() < 1e-6);
1256 assert!(IceDrip::droplet_alpha(0.99) < 0.2, "the droplet fades near the end");
1257 }
1258}