1use crate::animations::{
30 AnimatableProperty, AnimatedNode, Driver, Easing, Lerp, Runner, build_runner,
31 build_ui_transform,
32};
33use bevy::ecs::query::QueryData;
34use bevy::prelude::*;
35use bevy::ui::{ScrollPosition, UiTransform};
36use serde::Deserialize;
37
38use crate::protocol::{AnimatableField, Length, Style, Time as WireTime};
39use crate::ui_map::{length_to_val, parse_color};
40
41mod transform3d;
42
43#[derive(Debug, Clone, Default, Deserialize)]
48#[serde(rename_all = "camelCase")]
49pub struct Transition {
50 pub all: Option<ChannelTransition>,
52 pub transform: Option<ChannelTransition>,
54 pub opacity: Option<ChannelTransition>,
55 pub background_color: Option<ChannelTransition>,
56 pub size: Option<ChannelTransition>,
60 pub scroll: Option<ChannelTransition>,
66 pub filter: Option<ChannelTransition>,
75 pub backdrop_filter: Option<ChannelTransition>,
83 pub transform3d: Option<ChannelTransition>,
91}
92
93impl Transition {
94 pub fn for_transform(&self) -> Option<&ChannelTransition> {
96 self.transform.as_ref().or(self.all.as_ref())
97 }
98 pub fn for_opacity(&self) -> Option<&ChannelTransition> {
100 self.opacity.as_ref().or(self.all.as_ref())
101 }
102 pub fn for_background(&self) -> Option<&ChannelTransition> {
104 self.background_color.as_ref().or(self.all.as_ref())
105 }
106 pub fn for_size(&self) -> Option<&ChannelTransition> {
108 self.size.as_ref().or(self.all.as_ref())
109 }
110 pub fn for_scroll(&self) -> Option<&ChannelTransition> {
112 self.scroll.as_ref().or(self.all.as_ref())
113 }
114 pub fn for_filter(&self) -> Option<&ChannelTransition> {
116 self.filter.as_ref().or(self.all.as_ref())
117 }
118 pub fn for_backdrop_filter(&self) -> Option<&ChannelTransition> {
120 self.backdrop_filter.as_ref().or(self.all.as_ref())
121 }
122 pub fn for_transform3d(&self) -> Option<&ChannelTransition> {
124 self.transform3d.as_ref().or(self.all.as_ref())
125 }
126}
127
128#[derive(Debug, Clone, Deserialize)]
133#[serde(rename_all = "camelCase")]
134pub struct ChannelTransition {
135 pub duration: Option<WireTime>,
137 #[serde(default)]
138 pub easing: Easing,
139 #[serde(default)]
141 pub delay: WireTime,
142 pub stiffness: Option<f32>,
144 pub damping: Option<f32>,
145 #[serde(default = "default_mass")]
146 pub mass: f32,
147}
148
149fn default_mass() -> f32 {
150 1.0
151}
152
153impl ChannelTransition {
154 fn to_driver(&self, to: f32) -> Driver {
158 if self.stiffness.is_some() || self.damping.is_some() {
159 Driver::Spring {
160 to,
161 stiffness: self.stiffness.unwrap_or(100.0),
162 damping: self.damping.unwrap_or(10.0),
163 mass: self.mass,
164 }
165 } else {
166 let timing = Driver::Timing {
167 to,
168 duration: self.duration.map(WireTime::seconds).unwrap_or(0.3),
169 easing: self.easing,
170 };
171 let delay = self.delay.seconds();
172 if delay > 0.0 {
173 Driver::Delay {
174 delay,
175 animation: Box::new(timing),
176 }
177 } else {
178 timing
179 }
180 }
181 }
182}
183
184#[derive(Component, Debug, Clone, Default)]
190pub struct TransitionInput {
191 pub spec: Transition,
192 pub translate_x: Option<Length>,
193 pub translate_y: Option<Length>,
194 pub scale: Option<f32>,
195 pub scale_x: Option<f32>,
196 pub scale_y: Option<f32>,
197 pub rotate: Option<f32>,
198 pub opacity: Option<f32>,
199 pub background_color: Option<[f32; 4]>,
202 pub width: Option<Length>,
204 pub height: Option<Length>,
205 pub max_width: Option<Length>,
206 pub max_height: Option<Length>,
207 pub transform3d: Option<crate::protocol::Transform3d>,
210}
211
212impl TransitionInput {
213 fn from_style(style: &Style) -> Option<Self> {
215 let spec = style.transition.clone()?;
216 let t = style.transform.clone().unwrap_or_default();
217 Some(Self {
221 spec,
222 translate_x: t.translate_x.static_val(),
223 translate_y: t.translate_y.static_val(),
224 scale: t.scale.static_val(),
225 scale_x: t.scale_x.static_val(),
226 scale_y: t.scale_y.static_val(),
227 rotate: t.rotate.static_val().map(crate::protocol::Angle::radians),
228 opacity: style.opacity.static_val(),
229 background_color: style
230 .background_color
231 .static_ref()
232 .map(|hex| color_to_rgba(parse_color(hex))),
233 width: style.width.static_val(),
234 height: style.height.static_val(),
235 max_width: style.max_width.static_val(),
236 max_height: style.max_height.static_val(),
237 transform3d: style.transform3d.clone(),
238 })
239 }
240}
241
242#[derive(Component, Default)]
247#[require(UiTransform)]
248pub struct TransitionState {
249 translate_x: ProgressChannel<Length>,
250 translate_y: ProgressChannel<Length>,
251 scale: Channel,
252 scale_x: Channel,
253 scale_y: Channel,
254 rotate: Channel,
255 opacity: Channel,
256 color: ProgressChannel<[f32; 4]>,
257 width: ProgressChannel<Length>,
258 height: ProgressChannel<Length>,
259 max_width: ProgressChannel<Length>,
260 max_height: ProgressChannel<Length>,
261 filter: FilterChannel,
262 backdrop_filter: FilterChannel,
263 transform3d: transform3d::Transform3dChannels,
264 initialized: bool,
265}
266
267#[derive(Default)]
276struct FilterChannel {
277 wire: crate::filters::FilterChain,
279 current: Vec<crate::filters::ResolvedFilterPass>,
282 ease: Option<ActiveFilterEase>,
285}
286
287struct ActiveFilterEase {
291 runner: Runner,
292 ease: crate::filters::FilterEase,
293}
294
295impl FilterChannel {
296 fn drive(
321 &mut self,
322 input: Option<&crate::filters::FilterChain>,
323 mut resolved: Option<Mut<crate::filters::ResolvedFilterChain>>,
324 spec: Option<&ChannelTransition>,
325 registry: Option<&crate::filters::FilterRegistry>,
326 assets: Option<&AssetServer>,
327 dt: f32,
328 ) -> bool {
329 let retargeted = match input {
330 Some(fi) => *fi != self.wire,
331 None => !self.wire.0.is_empty(),
332 };
333 if retargeted {
334 let to_wire = input.cloned().unwrap_or_default();
335 let from_wire = std::mem::replace(&mut self.wire, to_wire);
336 match (spec, resolved.as_deref()) {
337 (Some(spec), Some(chain)) if !self.wire.0.is_empty() => {
342 self.ease = Some(ActiveFilterEase {
343 runner: build_runner(&spec.to_driver(1.0), 0.0),
344 ease: crate::filters::plan_filter_ease(
345 &from_wire,
346 &self.wire,
347 self.current.clone(),
348 chain.passes.clone(),
349 registry,
350 assets,
351 chain.scale,
352 ),
353 });
354 }
355 _ => {
356 self.current = resolved
358 .as_deref()
359 .map(|c| c.passes.clone())
360 .unwrap_or_default();
361 self.ease = None;
362 }
363 }
364 }
365 let mut wrote = false;
366 if let Some(mut active) = self.ease.take() {
367 match resolved.as_mut() {
368 Some(resolved) => {
369 let (p, done) = active.runner.step(dt);
370 let new = if done {
375 active.ease.settle().to_vec()
376 } else {
377 active.ease.sample(p)
378 };
379 if resolved.passes != new {
382 let chain = &mut **resolved;
383 chain.passes = new.clone();
384 chain.version = chain.version.wrapping_add(1);
385 wrote = true;
386 }
387 self.current = new;
388 if !done {
389 self.ease = Some(active);
390 }
391 }
392 None => {
393 self.current = Vec::new();
396 }
397 }
398 }
399 wrote
400 }
401}
402
403#[derive(Default)]
405struct Channel {
406 current: f32,
407 target: f32,
408 runner: Option<Runner>,
409}
410
411impl Channel {
412 fn init(&mut self, value: f32) {
415 self.current = value;
416 self.target = value;
417 self.runner = None;
418 }
419
420 fn drive(&mut self, target: f32, spec: Option<&ChannelTransition>, dt: f32) -> f32 {
423 if target != self.target {
424 self.target = target;
425 match spec {
426 Some(s) => self.runner = Some(build_runner(&s.to_driver(target), self.current)),
427 None => {
428 self.current = target;
429 self.runner = None;
430 }
431 }
432 }
433 if let Some(r) = self.runner.as_mut() {
434 let (v, done) = r.step(dt);
435 self.current = v;
436 if done {
437 self.runner = None;
438 }
439 }
440 self.current
441 }
442}
443
444#[derive(Default)]
452struct ProgressChannel<T> {
453 current: T,
454 target: T,
455 start: T,
456 runner: Option<Runner>,
457}
458
459impl<T: Lerp + PartialEq> ProgressChannel<T> {
460 fn init(&mut self, value: T) {
463 self.current = value;
464 self.target = value;
465 self.runner = None;
466 }
467
468 fn drive(&mut self, target: T, spec: Option<&ChannelTransition>, dt: f32) -> T {
471 if target != self.target {
472 self.target = target;
473 match spec {
474 Some(s) => {
475 self.start = self.current;
476 self.runner = Some(build_runner(&s.to_driver(1.0), 0.0));
477 }
478 None => {
479 self.current = target;
480 self.runner = None;
481 }
482 }
483 }
484 if let Some(r) = self.runner.as_mut() {
485 let (p, done) = r.step(dt);
486 self.current = self.start.lerp(self.target, p);
487 if done {
488 self.current = self.target;
489 self.runner = None;
490 }
491 }
492 self.current
493 }
494}
495
496impl Lerp for Length {
499 fn lerp(self, other: Self, t: f32) -> Self {
500 use Length::*;
501 let lerp = |x: f32, y: f32| x + (y - x) * t;
502 match (self, other) {
503 (Px(x), Px(y)) => Px(lerp(x, y)),
504 (Percent(x), Percent(y)) => Percent(lerp(x, y)),
505 (Vw(x), Vw(y)) => Vw(lerp(x, y)),
506 (Vh(x), Vh(y)) => Vh(lerp(x, y)),
507 (VMin(x), VMin(y)) => VMin(lerp(x, y)),
508 (VMax(x), VMax(y)) => VMax(lerp(x, y)),
509 _ => other,
510 }
511 }
512}
513
514#[derive(Component, Debug, Clone)]
520pub struct ScrollTransitionInput(pub ChannelTransition);
521
522#[derive(Component, Default)]
528pub struct ScrollTransitionState {
529 pub(crate) target: Vec2,
531 x: Channel,
532 y: Channel,
533 initialized: bool,
534}
535
536impl ScrollTransitionState {
537 pub(crate) fn snap_to(&mut self, value: Vec2) {
541 self.target = value;
542 self.x.init(value.x);
543 self.y.init(value.y);
544 self.initialized = true;
545 }
546}
547
548pub fn apply_scroll_transition(ec: &mut EntityCommands, style: &Option<Style>) {
553 match style
554 .as_ref()
555 .and_then(|s| s.transition.as_ref())
556 .and_then(|t| t.for_scroll())
557 {
558 Some(spec) => {
559 ec.insert(ScrollTransitionInput(spec.clone()));
560 ec.insert_if_new(ScrollTransitionState::default());
561 }
562 None => {
563 ec.remove::<ScrollTransitionInput>();
564 ec.remove::<ScrollTransitionState>();
565 }
566 }
567}
568
569pub fn drive_scroll_transition(
580 time: Res<Time>,
581 mut query: Query<(
582 &ScrollTransitionInput,
583 &mut ScrollTransitionState,
584 &mut ScrollPosition,
585 )>,
586) {
587 let dt = time.delta_secs();
588 for (input, mut state, mut pos) in &mut query {
589 if !state.initialized {
592 state.x.init(pos.0.x);
593 state.y.init(pos.0.y);
594 state.target = pos.0;
595 state.initialized = true;
596 }
597 let current = Vec2::new(state.x.current, state.y.current);
602 if pos.0 != current {
603 state.snap_to(pos.0);
604 continue;
605 }
606 let spec = &input.0;
607 let target = state.target;
608 let nx = state.x.drive(target.x, Some(spec), dt);
609 let ny = state.y.drive(target.y, Some(spec), dt);
610 if pos.0.x != nx || pos.0.y != ny {
612 pos.0 = Vec2::new(nx, ny);
613 }
614 }
615}
616
617pub fn apply_transition(ec: &mut EntityCommands, style: &Option<Style>) {
622 match style.as_ref().and_then(TransitionInput::from_style) {
623 Some(input) => {
624 ec.insert(input);
625 ec.insert_if_new(TransitionState::default());
627 }
628 None => {
629 ec.remove::<TransitionInput>();
630 ec.remove::<TransitionState>();
631 }
632 }
633}
634
635#[derive(QueryData)]
644#[query_data(mutable)]
645pub struct TransitionTargets {
646 transform: &'static mut UiTransform,
647 bg: Option<&'static mut BackgroundColor>,
648 text: Option<&'static mut TextColor>,
649 image: Option<&'static mut ImageNode>,
650 node: Option<&'static mut Node>,
651 anim: Option<&'static AnimatedNode>,
653 promoted: Option<&'static crate::layer::PromotedLayer>,
656 layer_alpha: Option<&'static mut crate::layer::LayerGroupAlpha>,
657 filter_input: Option<&'static crate::filters::FilterInput>,
662 resolved_filter: Option<&'static mut crate::filters::ResolvedFilterChain>,
666 backdrop_input: Option<&'static crate::filters::BackdropInput>,
669 resolved_backdrop: Option<&'static mut crate::filters::ResolvedBackdropChain>,
672 transform3d: Option<&'static mut crate::layer::transform3d::LayerTransform3d>,
676}
677
678pub fn drive_transitions(
683 time: Res<Time>,
684 mut commands: Commands,
685 mut dirt: ResMut<crate::layer::LayerContentDirt>,
686 filter_registry: Option<Res<crate::filters::FilterRegistry>>,
691 assets: Option<Res<AssetServer>>,
692 mut query: Query<(
693 Entity,
694 &TransitionInput,
695 &mut TransitionState,
696 TransitionTargets,
697 )>,
698) {
699 let dt = time.delta_secs();
700 for (entity, input, mut state, mut targets) in &mut query {
701 if !state.initialized {
704 state
705 .translate_x
706 .init(input.translate_x.unwrap_or(Length::Px(0.0)));
707 state
708 .translate_y
709 .init(input.translate_y.unwrap_or(Length::Px(0.0)));
710 state.scale.init(input.scale.unwrap_or(1.0));
711 state.scale_x.init(input.scale_x.unwrap_or(1.0));
712 state.scale_y.init(input.scale_y.unwrap_or(1.0));
713 state.rotate.init(input.rotate.unwrap_or(0.0));
714 state.opacity.init(input.opacity.unwrap_or(1.0));
715 if let Some(c) = input.background_color {
716 state.color.init(c);
717 }
718 state.width.init(input.width.unwrap_or(Length::Auto));
719 state.height.init(input.height.unwrap_or(Length::Auto));
720 state
721 .max_width
722 .init(input.max_width.unwrap_or(Length::Auto));
723 state
724 .max_height
725 .init(input.max_height.unwrap_or(Length::Auto));
726 state.filter.wire = targets
730 .filter_input
731 .map(|f| f.0.clone())
732 .unwrap_or_default();
733 state.filter.current = targets
734 .resolved_filter
735 .as_deref()
736 .map(|c| c.passes.clone())
737 .unwrap_or_default();
738 state.backdrop_filter.wire = targets
739 .backdrop_input
740 .map(|f| f.0.clone())
741 .unwrap_or_default();
742 state.backdrop_filter.current = targets
743 .resolved_backdrop
744 .as_deref()
745 .map(|c| c.0.passes.clone())
746 .unwrap_or_default();
747 state
748 .transform3d
749 .init(&input.transform3d.clone().unwrap_or_default());
750 state.initialized = true;
751 }
752
753 let skip_transform = targets.anim.is_some_and(|a| a.0.has_transform());
755 let skip_opacity = targets
756 .anim
757 .is_some_and(|a| a.0.contains(AnimatableProperty::Opacity));
758 let skip_bg = targets
759 .anim
760 .is_some_and(|a| a.0.contains(AnimatableProperty::BackgroundColor));
761 let skip_filter = targets.anim.is_some_and(|a| a.0.has_filter_params());
767 let skip_backdrop = targets.anim.is_some_and(|a| a.0.has_backdrop_params());
770 let skip_transform3d = targets.anim.is_some_and(|a| a.0.has_transform3d());
773
774 if input.spec.for_transform().is_some() && !skip_transform {
779 let s = input.spec.for_transform();
780 let tx = input
781 .translate_x
782 .map(|t| length_to_val(state.translate_x.drive(t, s, dt)));
783 let ty = input
784 .translate_y
785 .map(|t| length_to_val(state.translate_y.drive(t, s, dt)));
786 let sc = input.scale.map(|t| state.scale.drive(t, s, dt));
787 let scx = input.scale_x.map(|t| state.scale_x.drive(t, s, dt));
788 let scy = input.scale_y.map(|t| state.scale_y.drive(t, s, dt));
789 let rot = input.rotate.map(|t| state.rotate.drive(t, s, dt));
790 let new = build_ui_transform(tx, ty, sc, scx, scy, rot);
793 if *targets.transform != new {
794 let translate_only = targets.transform.scale == new.scale
797 && targets.transform.rotation == new.rotation;
798 if targets.promoted.is_some() && translate_only {
799 dirt.composite_only.push(entity);
800 } else {
801 dirt.nodes.push(entity);
802 }
803 *targets.transform = new;
804 }
805 }
806
807 if input.spec.for_transform3d().is_some()
814 && !skip_transform3d
815 && let Some(target) = &input.transform3d
816 && let Some(t3d) = &mut targets.transform3d
817 {
818 let new = state
819 .transform3d
820 .drive(target, input.spec.for_transform3d(), dt);
821 if t3d.0 != new {
824 t3d.0 = new;
825 }
826 }
827
828 let alpha = if !skip_opacity && let Some(target) = input.opacity {
833 Some(state.opacity.drive(target, input.spec.for_opacity(), dt))
834 } else {
835 None
836 };
837
838 let promoted = targets.promoted.is_some();
843 if !skip_bg && let Some(target) = input.background_color {
844 let mut rgba = state.color.drive(target, input.spec.for_background(), dt);
845 if let Some(a) = alpha
846 && !promoted
847 {
848 rgba[3] = a;
849 }
850 let color = rgba_to_color(rgba);
851 match &mut targets.bg {
852 Some(c) if c.0 != color => {
853 c.0 = color;
854 dirt.nodes.push(entity);
855 }
856 Some(_) => {}
857 None => {
858 commands.entity(entity).insert(BackgroundColor(color));
859 dirt.nodes.push(entity);
860 }
861 }
862 }
863
864 if let Some(alpha) = alpha
868 && promoted
869 {
870 if let Some(la) = &mut targets.layer_alpha
871 && la.0 != alpha
872 {
873 la.0 = alpha;
874 dirt.composite_only.push(entity);
877 }
878 } else if let Some(alpha) = alpha {
879 let mut wrote = false;
880 if let Some(c) = &mut targets.bg
881 && c.0.alpha() != alpha
882 {
883 c.0 = c.0.with_alpha(alpha);
884 wrote = true;
885 }
886 if let Some(tc) = &mut targets.text
887 && tc.0.alpha() != alpha
888 {
889 tc.0 = tc.0.with_alpha(alpha);
890 wrote = true;
891 }
892 if let Some(img) = &mut targets.image
893 && img.color.alpha() != alpha
894 {
895 img.color = img.color.with_alpha(alpha);
896 wrote = true;
897 }
898 if wrote {
899 dirt.nodes.push(entity);
900 }
901 }
902
903 if input.spec.for_size().is_some()
910 && let Some(node) = targets.node.as_mut()
911 {
912 let s = input.spec.for_size();
913 if let Some(t) = input.width {
914 let v = length_to_val(state.width.drive(t, s, dt));
915 if node.width != v {
916 node.width = v;
917 }
918 }
919 if let Some(t) = input.height {
920 let v = length_to_val(state.height.drive(t, s, dt));
921 if node.height != v {
922 node.height = v;
923 }
924 }
925 if let Some(t) = input.max_width {
926 let v = length_to_val(state.max_width.drive(t, s, dt));
927 if node.max_width != v {
928 node.max_width = v;
929 }
930 }
931 if let Some(t) = input.max_height {
932 let v = length_to_val(state.max_height.drive(t, s, dt));
933 if node.max_height != v {
934 node.max_height = v;
935 }
936 }
937 }
938
939 if !skip_filter
943 && state.filter.drive(
944 targets.filter_input.map(|f| &f.0),
945 targets.resolved_filter.as_mut().map(Mut::reborrow),
946 input.spec.for_filter(),
947 filter_registry.as_deref(),
948 assets.as_deref(),
949 dt,
950 )
951 {
952 dirt.composite_only.push(entity);
953 }
954
955 if !skip_backdrop
959 && state.backdrop_filter.drive(
960 targets.backdrop_input.map(|f| &f.0),
961 targets
962 .resolved_backdrop
963 .as_mut()
964 .map(|m| m.reborrow().map_unchanged(|b| &mut b.0)),
965 input.spec.for_backdrop_filter(),
966 filter_registry.as_deref(),
967 assets.as_deref(),
968 dt,
969 )
970 {
971 dirt.composite_only.push(entity);
972 }
973 }
974}
975
976fn color_to_rgba(color: Color) -> [f32; 4] {
977 let s = color.to_srgba();
978 [s.red, s.green, s.blue, s.alpha]
979}
980
981fn rgba_to_color(rgba: [f32; 4]) -> Color {
982 Color::srgba(rgba[0], rgba[1], rgba[2], rgba[3])
983}
984
985#[cfg(test)]
986mod tests {
987 use super::*;
988 use crate::animations::AnimatedBindings;
989 use std::time::Duration;
990
991 fn timing(duration: f32, easing: Easing) -> ChannelTransition {
992 ChannelTransition {
993 duration: Some(WireTime::from_secs(duration)),
994 easing,
995 delay: WireTime::from_secs(0.0),
996 stiffness: None,
997 damping: None,
998 mass: 1.0,
999 }
1000 }
1001
1002 fn parse<T: serde::de::DeserializeOwned>(json: serde_json::Value) -> T {
1003 serde_json::from_value(json).expect("valid json")
1004 }
1005
1006 #[test]
1007 fn channel_resolution_falls_back_to_all() {
1008 let t: Transition = parse(serde_json::json!({
1009 "all": { "duration": 100 },
1010 "opacity": { "duration": 200 },
1011 }));
1012 let secs = |c: &ChannelTransition| c.duration.map(WireTime::seconds);
1015 assert!(t.for_opacity().is_some());
1016 assert_eq!(secs(t.for_opacity().unwrap()), Some(0.2));
1017 assert_eq!(secs(t.for_transform().unwrap()), Some(0.1));
1018 assert_eq!(secs(t.for_background().unwrap()), Some(0.1));
1019
1020 let t: Transition = parse(serde_json::json!({ "opacity": { "duration": 50 } }));
1022 assert!(t.for_transform().is_none());
1023 assert!(t.for_opacity().is_some());
1024 }
1025
1026 #[test]
1029 fn filter_channel_falls_back_to_all() {
1030 let secs = |c: &ChannelTransition| c.duration.map(WireTime::seconds);
1031 let t: Transition = parse(serde_json::json!({
1032 "all": { "duration": 100 },
1033 "filter": { "duration": 400 },
1034 }));
1035 assert_eq!(secs(t.for_filter().unwrap()), Some(0.4));
1036
1037 let t: Transition = parse(serde_json::json!({ "all": { "duration": 100 } }));
1038 assert_eq!(secs(t.for_filter().unwrap()), Some(0.1));
1039
1040 let t: Transition = parse(serde_json::json!({ "opacity": { "duration": 50 } }));
1041 assert!(t.for_filter().is_none());
1042 }
1043
1044 #[test]
1045 fn to_driver_selects_spring_or_timing() {
1046 let spring = ChannelTransition {
1047 duration: None,
1048 easing: Easing::Linear,
1049 delay: WireTime::from_secs(0.0),
1050 stiffness: Some(120.0),
1051 damping: Some(14.0),
1052 mass: 1.0,
1053 };
1054 assert!(matches!(spring.to_driver(1.0), Driver::Spring { .. }));
1055 assert!(matches!(
1056 timing(0.3, Easing::Linear).to_driver(1.0),
1057 Driver::Timing { .. }
1058 ));
1059 let delayed = ChannelTransition {
1061 delay: WireTime::from_secs(0.2),
1062 ..timing(0.3, Easing::Linear)
1063 };
1064 assert!(matches!(delayed.to_driver(1.0), Driver::Delay { .. }));
1065 }
1066
1067 #[test]
1068 fn channel_snaps_without_spec_and_eases_with_one() {
1069 let mut ch = Channel::default();
1071 ch.init(1.0);
1072 assert_eq!(ch.drive(0.5, None, 0.016), 0.5);
1073
1074 let mut ch = Channel::default();
1076 ch.init(1.0);
1077 let spec = timing(1.0, Easing::Linear);
1078 ch.drive(0.0, Some(&spec), 0.0); let v = ch.drive(0.0, Some(&spec), 0.5); assert!((v - 0.5).abs() < 1e-3, "halfway expected ~0.5, got {v}");
1081 let v = ch.drive(0.0, Some(&spec), 0.5);
1082 assert!((v - 0.0).abs() < 1e-3, "end expected 0, got {v}");
1083 assert!(ch.runner.is_none(), "runner dropped once finished");
1084 }
1085
1086 #[test]
1087 fn color_channel_lerps_to_target() {
1088 let mut c = ProgressChannel::<[f32; 4]>::default();
1089 c.init([0.0, 0.0, 0.0, 1.0]);
1090 let spec = timing(1.0, Easing::Linear);
1091 c.drive([1.0, 0.5, 0.0, 1.0], Some(&spec), 0.0); let mid = c.drive([1.0, 0.5, 0.0, 1.0], Some(&spec), 0.5);
1093 assert!((mid[0] - 0.5).abs() < 1e-3);
1094 assert!((mid[1] - 0.25).abs() < 1e-3);
1095 assert!((mid[2] - 0.0).abs() < 1e-3);
1096 }
1097
1098 fn drive_world() -> (World, Schedule) {
1100 let mut world = World::new();
1101 world.init_resource::<crate::layer::LayerContentDirt>();
1102 world.insert_resource(Time::<()>::default());
1103 let mut schedule = Schedule::default();
1104 schedule.add_systems(drive_transitions);
1105 (world, schedule)
1106 }
1107
1108 fn advance(world: &mut World, secs: f32) {
1109 world
1110 .resource_mut::<Time>()
1111 .advance_by(Duration::from_secs_f32(secs));
1112 }
1113
1114 #[test]
1115 fn system_eases_scale_on_press_then_release() {
1116 let (mut world, mut schedule) = drive_world();
1117 let spec = Transition {
1118 transform: Some(timing(1.0, Easing::Linear)),
1119 ..Default::default()
1120 };
1121 let e = world
1122 .spawn((
1123 TransitionInput {
1124 spec: spec.clone(),
1125 scale: Some(1.0),
1126 ..Default::default()
1127 },
1128 TransitionState::default(),
1129 UiTransform::default(),
1130 ))
1131 .id();
1132
1133 schedule.run(&mut world);
1135 assert_eq!(world.entity(e).get::<UiTransform>().unwrap().scale.x, 1.0);
1136
1137 world
1139 .entity_mut(e)
1140 .get_mut::<TransitionInput>()
1141 .unwrap()
1142 .scale = Some(0.95);
1143 advance(&mut world, 0.5);
1144 schedule.run(&mut world);
1145 let sx = world.entity(e).get::<UiTransform>().unwrap().scale.x;
1146 assert!(
1147 (sx - 0.975).abs() < 1e-2,
1148 "mid-press expected ~0.975, got {sx}"
1149 );
1150
1151 advance(&mut world, 0.5);
1153 schedule.run(&mut world);
1154 let sx = world.entity(e).get::<UiTransform>().unwrap().scale.x;
1155 assert!((sx - 0.95).abs() < 1e-3, "pressed expected 0.95, got {sx}");
1156
1157 world
1159 .entity_mut(e)
1160 .get_mut::<TransitionInput>()
1161 .unwrap()
1162 .scale = Some(1.0);
1163 advance(&mut world, 0.5);
1164 schedule.run(&mut world);
1165 let sx = world.entity(e).get::<UiTransform>().unwrap().scale.x;
1166 assert!(
1167 (sx - 0.975).abs() < 1e-2,
1168 "mid-release expected ~0.975, got {sx}"
1169 );
1170 }
1171
1172 #[test]
1176 fn system_eases_transform3d() {
1177 use crate::layer::transform3d::LayerTransform3d;
1178 use crate::protocol::Transform3d;
1179
1180 let (mut world, mut schedule) = drive_world();
1181 let spec = Transition {
1182 transform3d: Some(timing(1.0, Easing::Linear)),
1183 ..Default::default()
1184 };
1185 let base = Transform3d::default();
1186 let e = world
1187 .spawn((
1188 TransitionInput {
1189 spec: spec.clone(),
1190 transform3d: Some(base.clone()),
1191 ..Default::default()
1192 },
1193 TransitionState::default(),
1194 UiTransform::default(),
1195 LayerTransform3d(base.clone()),
1196 ))
1197 .id();
1198
1199 schedule.run(&mut world);
1201 let t = world.entity(e).get::<LayerTransform3d>().unwrap().0.clone();
1202 assert!(t.is_identity());
1203
1204 let target = Transform3d {
1207 rotate_y: Some(crate::protocol::Animatable::Static(
1208 crate::protocol::Angle::from_radians(std::f32::consts::FRAC_PI_2),
1209 )),
1210 perspective: Some(crate::protocol::Animatable::Static(800.0)),
1211 ..Default::default()
1212 };
1213 world
1214 .entity_mut(e)
1215 .get_mut::<TransitionInput>()
1216 .unwrap()
1217 .transform3d = Some(target.clone());
1218 advance(&mut world, 0.5);
1219 schedule.run(&mut world);
1220 let t = world.entity(e).get::<LayerTransform3d>().unwrap().0.clone();
1221 let ry = t.rotate_y.static_val().unwrap().radians();
1222 assert!(
1223 (ry - std::f32::consts::FRAC_PI_4).abs() < 0.05,
1224 "mid-ease expected ~45°, got {}°",
1225 ry.to_degrees()
1226 );
1227 assert_eq!(
1228 t.perspective.static_val(),
1229 Some(800.0),
1230 "ortho→perspective snaps"
1231 );
1232
1233 advance(&mut world, 0.6);
1235 schedule.run(&mut world);
1236 let t = world.entity(e).get::<LayerTransform3d>().unwrap().0.clone();
1237 assert!(
1238 (t.rotate_y.static_val().unwrap().radians() - std::f32::consts::FRAC_PI_2).abs() < 1e-3
1239 );
1240
1241 let e2 = world
1243 .spawn((
1244 TransitionInput {
1245 spec: Transition {
1246 opacity: Some(timing(1.0, Easing::Linear)),
1247 ..Default::default()
1248 },
1249 transform3d: Some(target.clone()),
1250 ..Default::default()
1251 },
1252 TransitionState::default(),
1253 UiTransform::default(),
1254 LayerTransform3d(base),
1255 ))
1256 .id();
1257 schedule.run(&mut world);
1258 let t2 = world
1262 .entity(e2)
1263 .get::<LayerTransform3d>()
1264 .unwrap()
1265 .0
1266 .clone();
1267 assert!(t2.is_identity());
1268
1269 let e3 = world
1271 .spawn((
1272 TransitionInput {
1273 spec,
1274 transform3d: Some(target.clone()),
1275 ..Default::default()
1276 },
1277 TransitionState::default(),
1278 UiTransform::default(),
1279 ))
1280 .id();
1281 advance(&mut world, 0.1);
1282 schedule.run(&mut world);
1283 assert!(world.entity(e3).get::<LayerTransform3d>().is_none());
1284 }
1285
1286 #[test]
1287 fn system_eases_percent_translate() {
1288 let (mut world, mut schedule) = drive_world();
1289 let spec = Transition {
1290 transform: Some(timing(1.0, Easing::Linear)),
1291 ..Default::default()
1292 };
1293 let e = world
1294 .spawn((
1295 TransitionInput {
1296 spec,
1297 translate_x: Some(Length::Percent(0.0)),
1298 ..Default::default()
1299 },
1300 TransitionState::default(),
1301 UiTransform::default(),
1302 ))
1303 .id();
1304
1305 schedule.run(&mut world);
1307 assert_eq!(
1308 world.entity(e).get::<UiTransform>().unwrap().translation.x,
1309 Val::Percent(0.0)
1310 );
1311
1312 world
1315 .entity_mut(e)
1316 .get_mut::<TransitionInput>()
1317 .unwrap()
1318 .translate_x = Some(Length::Percent(100.0));
1319 advance(&mut world, 0.5);
1320 schedule.run(&mut world);
1321 let tx = world.entity(e).get::<UiTransform>().unwrap().translation.x;
1322 assert!(
1323 matches!(tx, Val::Percent(v) if (v - 50.0).abs() < 1.0),
1324 "mid expected ~50%, got {tx:?}"
1325 );
1326
1327 advance(&mut world, 0.5);
1328 schedule.run(&mut world);
1329 assert_eq!(
1330 world.entity(e).get::<UiTransform>().unwrap().translation.x,
1331 Val::Percent(100.0)
1332 );
1333 }
1334
1335 #[test]
1336 fn animated_style_channel_wins_over_transition() {
1337 let (mut world, mut schedule) = drive_world();
1338 let spec = Transition {
1339 transform: Some(timing(1.0, Easing::Linear)),
1340 ..Default::default()
1341 };
1342 let bindings = AnimatedBindings(
1345 [(
1346 crate::animations::AnimatableProperty::Scale,
1347 crate::animations::protocol::Binding::Shared { id: 1 },
1348 )]
1349 .into(),
1350 );
1351 let e = world
1352 .spawn((
1353 TransitionInput {
1354 spec,
1355 scale: Some(1.0),
1356 ..Default::default()
1357 },
1358 TransitionState::default(),
1359 UiTransform::from_scale(Vec2::splat(2.0)), AnimatedNode(bindings),
1361 ))
1362 .id();
1363
1364 schedule.run(&mut world);
1365 world
1366 .entity_mut(e)
1367 .get_mut::<TransitionInput>()
1368 .unwrap()
1369 .scale = Some(0.95);
1370 advance(&mut world, 0.5);
1371 schedule.run(&mut world);
1372 assert_eq!(world.entity(e).get::<UiTransform>().unwrap().scale.x, 2.0);
1374 }
1375
1376 #[test]
1382 fn filter_param_binding_gates_filter_transition() {
1383 use crate::animations::ValueKind;
1384 use std::sync::Arc;
1385
1386 let (mut world, mut schedule) = drive_world();
1387 let spec = Transition {
1388 filter: Some(timing(1.0, Easing::Linear)),
1389 ..Default::default()
1390 };
1391 let pass = |amount: f32| crate::filters::ResolvedFilterPass {
1392 shader: Handle::default(),
1393 params: vec![Vec4::new(amount, 0.0, 0.0, 0.0)],
1394 layout: Arc::from(vec![crate::filters::ParamSlot {
1395 name: "amount",
1396 kind: ValueKind::Scalar,
1397 vec: 0,
1398 comp: 0,
1399 len: 1,
1400 }]),
1401 wire_index: 0,
1402 };
1403 let wire = |amount: f32| -> crate::filters::FilterChain {
1404 serde_json::from_value(serde_json::json!(
1405 { "name": "grayscale", "params": { "amount": amount } }
1406 ))
1407 .unwrap()
1408 };
1409 let chain = |amount: f32| crate::filters::ResolvedFilterChain {
1410 passes: vec![pass(amount)],
1411 outset_px: 0,
1412 always_dirty: false,
1413 version: 1,
1414 scale: 1.0,
1415 };
1416 let bindings = AnimatedBindings(
1417 [(
1418 crate::animations::AnimatableProperty::FilterParam {
1419 index: 0,
1420 name: "amount".into(),
1421 },
1422 crate::animations::protocol::Binding::Shared { id: 1 },
1423 )]
1424 .into(),
1425 );
1426
1427 let spawn = |world: &mut World, gated: bool| {
1428 let mut e = world.spawn((
1429 TransitionInput {
1430 spec: spec.clone(),
1431 ..Default::default()
1432 },
1433 TransitionState::default(),
1434 UiTransform::default(),
1435 crate::filters::FilterInput(wire(0.0)),
1436 chain(0.0),
1437 ));
1438 if gated {
1439 e.insert(AnimatedNode(bindings.clone()));
1440 }
1441 e.id()
1442 };
1443 let gated = spawn(&mut world, true);
1444 let control = spawn(&mut world, false);
1445
1446 schedule.run(&mut world);
1448
1449 for e in [gated, control] {
1452 *world
1453 .entity_mut(e)
1454 .get_mut::<crate::filters::FilterInput>()
1455 .unwrap() = crate::filters::FilterInput(wire(1.0));
1456 let mut em = world.entity_mut(e);
1457 let mut c = em.get_mut::<crate::filters::ResolvedFilterChain>().unwrap();
1458 c.passes = vec![pass(1.0)];
1459 c.version = 2;
1460 }
1461 advance(&mut world, 0.1);
1462 schedule.run(&mut world);
1463
1464 let c = world
1467 .entity(control)
1468 .get::<crate::filters::ResolvedFilterChain>()
1469 .unwrap();
1470 let w = c.passes[0].params[0].x;
1471 assert!(
1472 w > 0.0 && w < 1.0,
1473 "control: transition eased over the snap, got {w}"
1474 );
1475 assert_eq!(c.version, 3, "control: transition bumped the version");
1476
1477 let c = world
1479 .entity(gated)
1480 .get::<crate::filters::ResolvedFilterChain>()
1481 .unwrap();
1482 assert_eq!(
1483 c.passes[0].params[0].x, 1.0,
1484 "gated: the transition must not touch the chain"
1485 );
1486 assert_eq!(c.version, 2, "gated: version stays the resolver's");
1487 }
1488
1489 #[test]
1493 fn settled_transition_does_not_dirty_components() {
1494 #[derive(Resource, Default)]
1495 struct Dirty(usize);
1496
1497 let (mut world, mut schedule) = drive_world();
1498 world.init_resource::<Dirty>();
1499 let spec = Transition {
1500 transform: Some(timing(0.2, Easing::Linear)),
1501 background_color: Some(timing(0.2, Easing::Linear)),
1502 opacity: Some(timing(0.2, Easing::Linear)),
1503 ..Default::default()
1504 };
1505 let e = world
1506 .spawn((
1507 TransitionInput {
1508 spec,
1509 scale: Some(1.0),
1510 opacity: Some(0.5),
1513 background_color: Some([1.0, 0.0, 0.0, 1.0]),
1514 ..Default::default()
1515 },
1516 TransitionState::default(),
1517 UiTransform::default(),
1518 BackgroundColor(Color::WHITE),
1519 ))
1520 .id();
1521
1522 type AnyTargetChanged = Or<(Changed<UiTransform>, Changed<BackgroundColor>)>;
1523
1524 let mut detect = Schedule::default();
1525 detect.add_systems(|q: Query<(), AnyTargetChanged>, mut dirty: ResMut<Dirty>| {
1526 dirty.0 = q.iter().count();
1527 });
1528
1529 schedule.run(&mut world);
1531 world
1532 .entity_mut(e)
1533 .get_mut::<TransitionInput>()
1534 .unwrap()
1535 .scale = Some(0.9);
1536 advance(&mut world, 0.5);
1537 schedule.run(&mut world);
1538 detect.run(&mut world); advance(&mut world, 0.5);
1541 schedule.run(&mut world);
1542 detect.run(&mut world);
1543 assert_eq!(
1544 world.resource::<Dirty>().0,
1545 0,
1546 "a settled transition must not dirty anything"
1547 );
1548 }
1549
1550 #[test]
1551 fn lerp_length_same_unit_else_snaps() {
1552 assert_eq!(Length::Px(0.0).lerp(Length::Px(10.0), 0.5), Length::Px(5.0));
1553 assert_eq!(
1554 Length::Percent(0.0).lerp(Length::Percent(100.0), 0.25),
1555 Length::Percent(25.0)
1556 );
1557 assert_eq!(Length::Auto.lerp(Length::Px(10.0), 0.5), Length::Px(10.0));
1559 assert_eq!(
1560 Length::Px(0.0).lerp(Length::Percent(10.0), 0.5),
1561 Length::Percent(10.0)
1562 );
1563 }
1564
1565 fn px(l: Length) -> f32 {
1566 match l {
1567 Length::Px(v) => v,
1568 other => panic!("expected Px, got {other:?}"),
1569 }
1570 }
1571
1572 #[test]
1573 fn length_channel_eases_then_idles() {
1574 let mut ch = ProgressChannel::<Length>::default();
1575 ch.init(Length::Px(0.0));
1576 let spec = timing(1.0, Easing::Linear);
1577 assert!((px(ch.drive(Length::Px(100.0), Some(&spec), 0.0)) - 0.0).abs() < 1e-3);
1579 assert!((px(ch.drive(Length::Px(100.0), Some(&spec), 0.5)) - 50.0).abs() < 1e-3);
1580 assert!((px(ch.drive(Length::Px(100.0), Some(&spec), 0.5)) - 100.0).abs() < 1e-3);
1581 assert!(ch.runner.is_none(), "runner dropped once settled");
1584 assert_eq!(
1585 ch.drive(Length::Px(100.0), Some(&spec), 0.5),
1586 Length::Px(100.0)
1587 );
1588 }
1589
1590 #[test]
1591 fn system_eases_max_height_layout() {
1592 let (mut world, mut schedule) = drive_world();
1593 let spec = Transition {
1594 size: Some(timing(1.0, Easing::Linear)),
1595 ..Default::default()
1596 };
1597 let e = world
1598 .spawn((
1599 TransitionInput {
1600 spec,
1601 max_height: Some(Length::Px(120.0)),
1602 ..Default::default()
1603 },
1604 TransitionState::default(),
1605 Node::default(),
1606 UiTransform::default(),
1607 ))
1608 .id();
1609
1610 schedule.run(&mut world);
1612
1613 world
1615 .entity_mut(e)
1616 .get_mut::<TransitionInput>()
1617 .unwrap()
1618 .max_height = Some(Length::Px(0.0));
1619 advance(&mut world, 0.5);
1620 schedule.run(&mut world);
1621 let mh = world.entity(e).get::<Node>().unwrap().max_height;
1622 assert!(
1623 matches!(mh, Val::Px(v) if (v - 60.0).abs() < 1.0),
1624 "mid expected ~60px, got {mh:?}"
1625 );
1626
1627 advance(&mut world, 0.5);
1628 schedule.run(&mut world);
1629 let mh = world.entity(e).get::<Node>().unwrap().max_height;
1630 assert!(
1631 matches!(mh, Val::Px(v) if v.abs() < 1e-3),
1632 "settled expected 0px, got {mh:?}"
1633 );
1634 }
1635
1636 #[test]
1639 fn system_eases_scroll_toward_target() {
1640 let mut world = World::new();
1641 world.init_resource::<crate::layer::LayerContentDirt>();
1642 world.insert_resource(Time::<()>::default());
1643 let mut schedule = Schedule::default();
1644 schedule.add_systems(drive_scroll_transition);
1645
1646 let e = world
1647 .spawn((
1648 ScrollTransitionInput(timing(1.0, Easing::Linear)),
1649 ScrollTransitionState::default(),
1650 ScrollPosition::default(),
1651 ))
1652 .id();
1653
1654 schedule.run(&mut world);
1656 assert_eq!(
1657 world.entity(e).get::<ScrollPosition>().unwrap().0,
1658 Vec2::ZERO
1659 );
1660
1661 world
1663 .entity_mut(e)
1664 .get_mut::<ScrollTransitionState>()
1665 .unwrap()
1666 .target = Vec2::new(0.0, 100.0);
1667 advance(&mut world, 0.5);
1668 schedule.run(&mut world);
1669 let y = world.entity(e).get::<ScrollPosition>().unwrap().0.y;
1670 assert!((y - 50.0).abs() < 1.0, "mid-ease expected ~50, got {y}");
1671
1672 advance(&mut world, 0.5);
1674 schedule.run(&mut world);
1675 assert_eq!(
1676 world.entity(e).get::<ScrollPosition>().unwrap().0,
1677 Vec2::new(0.0, 100.0)
1678 );
1679 }
1680
1681 #[test]
1685 fn scroll_direct_write_snaps_the_ease() {
1686 let mut world = World::new();
1687 world.init_resource::<crate::layer::LayerContentDirt>();
1688 world.insert_resource(Time::<()>::default());
1689 let mut schedule = Schedule::default();
1690 schedule.add_systems(drive_scroll_transition);
1691
1692 let e = world
1693 .spawn((
1694 ScrollTransitionInput(timing(1.0, Easing::Linear)),
1695 ScrollTransitionState::default(),
1696 ScrollPosition::default(),
1697 ))
1698 .id();
1699 schedule.run(&mut world); world
1703 .entity_mut(e)
1704 .get_mut::<ScrollTransitionState>()
1705 .unwrap()
1706 .target = Vec2::new(0.0, 100.0);
1707 advance(&mut world, 0.5);
1708 schedule.run(&mut world);
1709 let y = world.entity(e).get::<ScrollPosition>().unwrap().0.y;
1710 assert!(y > 0.0 && y < 100.0, "mid-ease expected, got {y}");
1711
1712 world.entity_mut(e).get_mut::<ScrollPosition>().unwrap().0 = Vec2::new(0.0, 42.0);
1714 advance(&mut world, 0.25);
1715 schedule.run(&mut world);
1716 assert_eq!(
1717 world.entity(e).get::<ScrollPosition>().unwrap().0,
1718 Vec2::new(0.0, 42.0)
1719 );
1720 advance(&mut world, 0.25);
1722 schedule.run(&mut world);
1723 assert_eq!(
1724 world.entity(e).get::<ScrollPosition>().unwrap().0,
1725 Vec2::new(0.0, 42.0)
1726 );
1727 }
1728
1729 #[test]
1732 fn scroll_snap_to_parks_a_mid_flight_ease() {
1733 let mut world = World::new();
1734 world.init_resource::<crate::layer::LayerContentDirt>();
1735 world.insert_resource(Time::<()>::default());
1736 let mut schedule = Schedule::default();
1737 schedule.add_systems(drive_scroll_transition);
1738
1739 let e = world
1740 .spawn((
1741 ScrollTransitionInput(timing(1.0, Easing::Linear)),
1742 ScrollTransitionState::default(),
1743 ScrollPosition::default(),
1744 ))
1745 .id();
1746 schedule.run(&mut world); world
1749 .entity_mut(e)
1750 .get_mut::<ScrollTransitionState>()
1751 .unwrap()
1752 .target = Vec2::new(0.0, 100.0);
1753 advance(&mut world, 0.5);
1754 schedule.run(&mut world);
1755 let live = world.entity(e).get::<ScrollPosition>().unwrap().0;
1756 assert!(
1757 live.y > 0.0 && live.y < 100.0,
1758 "mid-ease expected, got {live:?}"
1759 );
1760
1761 world
1762 .entity_mut(e)
1763 .get_mut::<ScrollTransitionState>()
1764 .unwrap()
1765 .snap_to(live);
1766 advance(&mut world, 0.5);
1767 schedule.run(&mut world);
1768 assert_eq!(world.entity(e).get::<ScrollPosition>().unwrap().0, live);
1769 advance(&mut world, 0.5);
1770 schedule.run(&mut world);
1771 assert_eq!(world.entity(e).get::<ScrollPosition>().unwrap().0, live);
1772 }
1773}