1use bevy_ecs::prelude::*;
20use lumen_core::components::{Color, Fill, Opacity, Visuals};
21use lumen_core::prelude::{Tick, TickStage};
22use lumen_core::time::{Duration, Instant};
23
24pub trait Lerp: Copy + PartialEq {
28 fn lerp(self, other: Self, t: f32) -> Self;
31}
32
33impl Lerp for f32 {
34 fn lerp(self, other: Self, t: f32) -> Self {
35 self + (other - self) * t
36 }
37}
38
39impl Lerp for Color {
40 fn lerp(self, other: Self, t: f32) -> Self {
41 Color {
42 r: self.r.lerp(other.r, t),
43 g: self.g.lerp(other.g, t),
44 b: self.b.lerp(other.b, t),
45 a: self.a.lerp(other.a, t),
46 }
47 }
48}
49
50#[derive(Clone, Copy, Debug, PartialEq)]
60pub enum Easing {
61 Linear,
63 EaseIn,
65 EaseOut,
67 EaseInOut,
69 CubicBezier(f32, f32, f32, f32),
72}
73
74impl Easing {
75 pub fn apply(self, t: f32) -> f32 {
77 let t = t.clamp(0.0, 1.0);
78 match self {
79 Easing::Linear => t,
80 Easing::EaseIn => t * t * t,
81 Easing::EaseOut => {
82 let inv = 1.0 - t;
83 1.0 - inv * inv * inv
84 }
85 Easing::EaseInOut => {
86 if t < 0.5 {
87 4.0 * t * t * t
88 } else {
89 let inv = -2.0 * t + 2.0;
90 1.0 - inv * inv * inv * 0.5
91 }
92 }
93 Easing::CubicBezier(p1x, p1y, p2x, p2y) => cubic_bezier(p1x, p1y, p2x, p2y, t),
94 }
95 }
96}
97
98#[derive(Component, Clone, Copy, Debug)]
106pub struct Transition<T: Lerp + Send + Sync + 'static> {
107 pub from: T,
109 pub to: T,
111 pub start: Instant,
113 pub duration: Duration,
115 pub easing: Easing,
117}
118
119impl<T: Lerp + Send + Sync + 'static> Transition<T> {
120 pub fn new(from: T, to: T, duration: Duration, easing: Easing) -> Self {
123 Self {
124 from,
125 to,
126 start: Instant::now(),
127 duration,
128 easing,
129 }
130 }
131
132 pub fn progress(&self, now: Instant) -> f32 {
136 if self.duration.is_zero() {
137 return 1.0;
138 }
139 let elapsed = now.saturating_duration_since(self.start).as_secs_f32();
140 (elapsed / self.duration.as_secs_f32()).clamp(0.0, 1.0)
141 }
142
143 pub fn sample(&self, now: Instant) -> T {
145 let t = self.easing.apply(self.progress(now));
146 self.from.lerp(self.to, t)
147 }
148
149 pub fn done(&self, now: Instant) -> bool {
151 self.progress(now) >= 1.0
152 }
153}
154
155impl<T: Lerp + Send + Sync + 'static> From<(T, T, Duration)> for Transition<T> {
156 fn from((from, to, duration): (T, T, Duration)) -> Self {
159 Self::new(from, to, duration, Easing::Linear)
160 }
161}
162
163#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
170pub enum TransitionProperty {
171 Opacity,
173 BackgroundColor,
177 TextColor,
180 BorderColor,
183}
184
185impl TransitionProperty {
186 pub fn from_css(name: &str) -> Option<Self> {
189 match name {
190 "opacity" => Some(Self::Opacity),
191 "background-color" | "background" | "bg" => Some(Self::BackgroundColor),
192 "color" | "text-color" => Some(Self::TextColor),
193 "border-color" => Some(Self::BorderColor),
194 _ => None,
195 }
196 }
197}
198
199#[derive(Clone, Copy, Debug, PartialEq)]
205pub struct TransitionSpec {
206 pub property: TransitionProperty,
208 pub duration: Duration,
210 pub easing: Easing,
212}
213
214#[derive(Component, Clone, Debug, Default, PartialEq)]
216pub struct TransitionSpecs(pub Vec<TransitionSpec>);
217
218impl TransitionSpecs {
219 pub fn for_property(&self, property: TransitionProperty) -> Option<&TransitionSpec> {
225 self.0.iter().rev().find(|s| s.property == property)
226 }
227}
228
229#[derive(Component, Clone, Copy, Debug)]
234pub struct OpacityTransition(pub Transition<f32>);
235
236#[derive(Component, Clone, Copy, Debug)]
243pub struct BackgroundTransition(pub Transition<Color>);
244
245#[derive(Component, Clone, Copy, Debug)]
247pub struct TextColorTransition(pub Transition<Color>);
248
249#[derive(Component, Clone, Copy, Debug)]
251pub struct BorderColorTransition(pub Transition<Color>);
252
253pub fn retarget<T: Lerp + Send + Sync + 'static>(
259 current: T,
260 to: T,
261 spec: &TransitionSpec,
262) -> Option<Transition<T>> {
263 if current == to {
264 return None;
265 }
266 Some(Transition::new(current, to, spec.duration, spec.easing))
267}
268
269pub fn step_opacity_transitions(
278 mut commands: Commands,
279 tick: Res<Tick>,
280 anim: Res<lumen_core::render_world::AnimationsActive>,
281 mut q: Query<(
282 Entity,
283 &OpacityTransition,
284 &mut Opacity,
285 Option<&lumen_core::components::Visible>,
286 )>,
287) {
288 let now = tick.now;
289 for (entity, tween, mut opacity, visible) in &mut q {
290 let hidden = visible.is_some_and(|v| !v.0);
291 let next = if hidden {
292 tween.0.to
293 } else {
294 tween.0.sample(now)
295 };
296 if opacity.0 != next {
297 opacity.0 = next;
298 }
299 if hidden || tween.0.done(now) {
300 commands.entity(entity).remove::<OpacityTransition>();
301 } else {
302 anim.request();
305 }
306 }
307}
308
309#[allow(clippy::type_complexity)]
316pub fn step_background_transitions(
317 mut commands: Commands,
318 tick: Res<Tick>,
319 anim: Res<lumen_core::render_world::AnimationsActive>,
320 mut q: Query<(
321 Entity,
322 &BackgroundTransition,
323 &mut Visuals,
324 Option<&lumen_core::components::Visible>,
325 Option<&lumen_core::style_overrides::StyleOverrides>,
326 )>,
327) {
328 use lumen_core::style_overrides::StyleFields;
329 let now = tick.now;
330 for (entity, tween, mut vis, visible, overrides) in &mut q {
331 if overrides.is_some_and(|o| o.held().contains(StyleFields::FILL)) {
332 commands.entity(entity).remove::<BackgroundTransition>();
333 continue;
334 }
335 let hidden = visible.is_some_and(|v| !v.0);
336 let next = if hidden {
337 tween.0.to
338 } else {
339 tween.0.sample(now)
340 };
341 match vis.fill.as_mut() {
342 Some(Fill::Solid(slot)) => {
343 if *slot != next {
344 *slot = next;
345 }
346 }
347 _ => {
348 commands.entity(entity).remove::<BackgroundTransition>();
349 continue;
350 }
351 }
352 if hidden || tween.0.done(now) {
353 commands.entity(entity).remove::<BackgroundTransition>();
354 } else {
355 anim.request();
356 }
357 }
358}
359
360pub fn step_text_color_transitions(
362 mut commands: Commands,
363 tick: Res<Tick>,
364 anim: Res<lumen_core::render_world::AnimationsActive>,
365 mut q: Query<(
366 Entity,
367 &TextColorTransition,
368 &mut lumen_core::components::TextStyle,
369 Option<&lumen_core::components::Visible>,
370 )>,
371) {
372 let now = tick.now;
373 for (entity, tween, mut style, visible) in &mut q {
374 let hidden = visible.is_some_and(|v| !v.0);
375 let next = if hidden {
376 tween.0.to
377 } else {
378 tween.0.sample(now)
379 };
380 if style.color != next {
381 style.color = next;
382 }
383 if hidden || tween.0.done(now) {
384 commands.entity(entity).remove::<TextColorTransition>();
385 } else {
386 anim.request();
387 }
388 }
389}
390
391pub fn step_border_color_transitions(
395 mut commands: Commands,
396 tick: Res<Tick>,
397 anim: Res<lumen_core::render_world::AnimationsActive>,
398 mut q: Query<(
399 Entity,
400 &BorderColorTransition,
401 &mut Visuals,
402 Option<&lumen_core::components::Visible>,
403 )>,
404) {
405 let now = tick.now;
406 for (entity, tween, mut vis, visible) in &mut q {
407 let hidden = visible.is_some_and(|v| !v.0);
408 let next = if hidden {
409 tween.0.to
410 } else {
411 tween.0.sample(now)
412 };
413 match vis.border.as_mut() {
414 Some(border) => {
415 if border.color != next {
416 border.color = next;
417 }
418 }
419 None => {
420 commands.entity(entity).remove::<BorderColorTransition>();
421 continue;
422 }
423 }
424 if hidden || tween.0.done(now) {
425 commands.entity(entity).remove::<BorderColorTransition>();
426 } else {
427 anim.request();
428 }
429 }
430}
431
432pub struct TransitionPlugin;
436
437impl lumen_core::prelude::Plugin for TransitionPlugin {
438 fn build(self, app: &mut lumen_core::prelude::App) {
439 app.add_systems(TickStage::Systems, step_opacity_transitions);
440 app.add_systems(
443 TickStage::Systems,
444 step_background_transitions.after(crate::hover::apply_press_tint),
445 );
446 app.add_systems(TickStage::Systems, step_text_color_transitions);
447 app.add_systems(TickStage::Systems, step_border_color_transitions);
448 }
449}
450
451fn cubic_bezier(p1x: f32, p1y: f32, p2x: f32, p2y: f32, t: f32) -> f32 {
456 let ax = 3.0 * p1x - 3.0 * p2x + 1.0;
457 let bx = -6.0 * p1x + 3.0 * p2x;
458 let cx = 3.0 * p1x;
459 let ay = 3.0 * p1y - 3.0 * p2y + 1.0;
460 let by = -6.0 * p1y + 3.0 * p2y;
461 let cy = 3.0 * p1y;
462 let bx_t = |s: f32| ((ax * s + bx) * s + cx) * s;
463 let dbx_t = |s: f32| (3.0 * ax * s + 2.0 * bx) * s + cx;
464 let by_t = |s: f32| ((ay * s + by) * s + cy) * s;
465 let mut s = t;
466 for _ in 0..3 {
467 let x = bx_t(s) - t;
468 let dx = dbx_t(s);
469 if dx.abs() < 1e-6 {
470 break;
471 }
472 s -= x / dx;
473 s = s.clamp(0.0, 1.0);
474 }
475 let mut lo = 0.0;
478 let mut hi = 1.0;
479 for _ in 0..16 {
480 let x = bx_t(s) - t;
481 if x.abs() < 1e-4 {
482 break;
483 }
484 if x < 0.0 {
485 lo = s;
486 } else {
487 hi = s;
488 }
489 s = 0.5 * (lo + hi);
490 }
491 by_t(s)
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497
498 fn approx(a: f32, b: f32) -> bool {
499 (a - b).abs() < 1e-3
500 }
501
502 #[test]
503 fn linear_easing_is_identity() {
504 assert!(approx(Easing::Linear.apply(0.0), 0.0));
505 assert!(approx(Easing::Linear.apply(0.5), 0.5));
506 assert!(approx(Easing::Linear.apply(1.0), 1.0));
507 }
508
509 #[test]
510 fn ease_in_starts_slow_ends_fast() {
511 let mid = Easing::EaseIn.apply(0.5);
512 assert!(mid < 0.2);
514 assert!(approx(Easing::EaseIn.apply(0.0), 0.0));
515 assert!(approx(Easing::EaseIn.apply(1.0), 1.0));
516 }
517
518 #[test]
519 fn ease_out_starts_fast_ends_slow() {
520 let mid = Easing::EaseOut.apply(0.5);
521 assert!(mid > 0.8);
523 assert!(approx(Easing::EaseOut.apply(0.0), 0.0));
524 assert!(approx(Easing::EaseOut.apply(1.0), 1.0));
525 }
526
527 #[test]
528 fn ease_in_out_passes_through_midpoint() {
529 assert!(approx(Easing::EaseInOut.apply(0.5), 0.5));
530 assert!(Easing::EaseInOut.apply(0.25) < 0.25);
532 assert!(Easing::EaseInOut.apply(0.75) > 0.75);
534 }
535
536 #[test]
537 fn cubic_bezier_matches_linear_anchors() {
538 assert!(approx(
541 Easing::CubicBezier(0.25, 0.1, 0.25, 1.0).apply(0.0),
542 0.0
543 ));
544 assert!(approx(
545 Easing::CubicBezier(0.25, 0.1, 0.25, 1.0).apply(1.0),
546 1.0
547 ));
548 let mid = Easing::CubicBezier(0.25, 0.1, 0.25, 1.0).apply(0.5);
550 assert!(mid > 0.5 && mid < 1.0);
551 }
552
553 #[test]
554 fn transition_samples_linearly() {
555 let t = Transition::<f32>::new(0.0, 10.0, Duration::from_millis(100), Easing::Linear);
556 let halfway = t.start + Duration::from_millis(50);
557 assert!(approx(t.sample(halfway), 5.0));
558 let done = t.start + Duration::from_millis(100);
559 assert!(approx(t.sample(done), 10.0));
560 assert!(t.done(done));
561 }
562
563 #[test]
564 fn transition_clamps_after_duration() {
565 let t = Transition::<f32>::new(0.0, 1.0, Duration::from_millis(100), Easing::Linear);
566 let after = t.start + Duration::from_millis(200);
567 assert!(approx(t.sample(after), 1.0));
568 assert!(t.done(after));
569 }
570
571 #[test]
572 fn transition_zero_duration_is_instant() {
573 let t = Transition::<f32>::new(0.0, 1.0, Duration::ZERO, Easing::Linear);
576 assert!(approx(t.sample(t.start), 1.0));
577 assert!(t.done(t.start));
578 }
579
580 #[test]
581 fn from_tuple_builds_linear_transition() {
582 let t: Transition<f32> = (0.0, 10.0, Duration::from_millis(100)).into();
583 assert_eq!(t.easing, Easing::Linear);
584 assert!(approx(t.sample(t.start + Duration::from_millis(50)), 5.0));
585 }
586
587 #[test]
588 fn for_property_returns_last_declaration() {
589 let specs = TransitionSpecs(vec![
591 TransitionSpec {
592 property: TransitionProperty::Opacity,
593 duration: Duration::from_millis(100),
594 easing: Easing::Linear,
595 },
596 TransitionSpec {
597 property: TransitionProperty::Opacity,
598 duration: Duration::from_millis(500),
599 easing: Easing::EaseOut,
600 },
601 ]);
602 let chosen = specs.for_property(TransitionProperty::Opacity).unwrap();
603 assert_eq!(chosen.duration, Duration::from_millis(500));
604 assert_eq!(chosen.easing, Easing::EaseOut);
605 }
606
607 #[test]
608 fn retarget_starts_from_current_value_and_skips_noops() {
609 let spec = TransitionSpec {
610 property: TransitionProperty::BackgroundColor,
611 duration: Duration::from_millis(200),
612 easing: Easing::Linear,
613 };
614 let red = Color::rgb(1.0, 0.0, 0.0);
615 let blue = Color::rgb(0.0, 0.0, 1.0);
616 let mid = red.lerp(blue, 0.5);
618 let t = retarget(mid, blue, &spec).expect("differing values start a tween");
619 assert_eq!(t.sample(t.start), mid);
622 assert_eq!(t.sample(t.start + Duration::from_millis(200)), blue);
623 assert!(retarget(blue, blue, &spec).is_none());
625 }
626
627 #[test]
628 fn background_driver_writes_solid_fill_and_retires() {
629 use bevy_ecs::system::RunSystemOnce;
630 let mut world = bevy_ecs::world::World::new();
631 world.insert_resource(Tick::default());
632 world.insert_resource(lumen_core::render_world::AnimationsActive::default());
633 let from = Color::rgb(0.0, 0.0, 0.0);
634 let to = Color::rgb(1.0, 1.0, 1.0);
635 let e = world
636 .spawn((
637 Visuals {
638 fill: Some(Fill::Solid(from)),
639 radius: 0.0,
640 corner_radii: None,
641 shadows: Vec::new(),
642 border: None,
643 },
644 BackgroundTransition(Transition::new(
645 from,
646 to,
647 Duration::from_millis(100),
648 Easing::Linear,
649 )),
650 ))
651 .id();
652 world.get_mut::<BackgroundTransition>(e).unwrap().0.start -= Duration::from_millis(50);
655 {
656 let now =
657 world.get::<BackgroundTransition>(e).unwrap().0.start + Duration::from_millis(50);
658 world.resource_mut::<Tick>().now = now;
659 }
660 world.run_system_once(step_background_transitions).unwrap();
661 let mid = world
662 .get::<Visuals>(e)
663 .unwrap()
664 .fill
665 .as_ref()
666 .and_then(Fill::as_solid)
667 .unwrap();
668 assert!((mid.r - 0.5).abs() < 0.05, "mid-flight fill (r={})", mid.r);
669 assert!(
670 world.get::<BackgroundTransition>(e).is_some(),
671 "tween still active mid-flight"
672 );
673 {
675 let now =
676 world.get::<BackgroundTransition>(e).unwrap().0.start + Duration::from_millis(500);
677 world.resource_mut::<Tick>().now = now;
678 }
679 world.run_system_once(step_background_transitions).unwrap();
680 let done = world
681 .get::<Visuals>(e)
682 .unwrap()
683 .fill
684 .as_ref()
685 .and_then(Fill::as_solid)
686 .unwrap();
687 assert_eq!(done, to);
688 assert!(world.get::<BackgroundTransition>(e).is_none());
689 }
690
691 #[test]
692 fn hidden_entity_ticks_no_animation() {
693 use bevy_ecs::system::RunSystemOnce;
694 let mut world = bevy_ecs::world::World::new();
695 world.insert_resource(Tick::default());
696 world.insert_resource(lumen_core::render_world::AnimationsActive::default());
697 let e = world
698 .spawn((
699 Opacity(0.0),
700 OpacityTransition(Transition::new(
701 0.0,
702 1.0,
703 Duration::from_millis(500),
704 Easing::Linear,
705 )),
706 lumen_core::components::Visible(false),
707 ))
708 .id();
709 world.run_system_once(step_opacity_transitions).unwrap();
710 assert_eq!(world.get::<Opacity>(e).unwrap().0, 1.0);
712 assert!(world.get::<OpacityTransition>(e).is_none());
713 assert!(
714 !world
715 .resource::<lumen_core::render_world::AnimationsActive>()
716 .get(),
717 "hidden transitions must not keep the loop awake"
718 );
719 }
720
721 #[test]
722 fn lerp_color() {
723 let a = Color {
724 r: 0.0,
725 g: 0.0,
726 b: 0.0,
727 a: 0.0,
728 };
729 let b = Color {
730 r: 1.0,
731 g: 1.0,
732 b: 1.0,
733 a: 1.0,
734 };
735 let mid = a.lerp(b, 0.5);
736 assert!(approx(mid.r, 0.5));
737 assert!(approx(mid.g, 0.5));
738 assert!(approx(mid.b, 0.5));
739 assert!(approx(mid.a, 0.5));
740 }
741}