1use crate::animations::{
30 AnimatableProperty, AnimatedNode, Driver, Easing, Lerp, Runner, build_runner,
31 build_ui_transform,
32};
33use bevy::prelude::*;
34use bevy::ui::{ScrollPosition, UiTransform};
35use serde::Deserialize;
36
37use crate::protocol::{Length, Style, Time as WireTime};
38use crate::ui_map::{length_to_val, parse_color};
39
40#[derive(Debug, Clone, Default, Deserialize)]
45#[serde(rename_all = "camelCase")]
46pub struct Transition {
47 pub all: Option<ChannelTransition>,
49 pub transform: Option<ChannelTransition>,
51 pub opacity: Option<ChannelTransition>,
52 pub background_color: Option<ChannelTransition>,
53 pub size: Option<ChannelTransition>,
57 pub scroll: Option<ChannelTransition>,
63}
64
65impl Transition {
66 pub fn for_transform(&self) -> Option<&ChannelTransition> {
68 self.transform.as_ref().or(self.all.as_ref())
69 }
70 pub fn for_opacity(&self) -> Option<&ChannelTransition> {
72 self.opacity.as_ref().or(self.all.as_ref())
73 }
74 pub fn for_background(&self) -> Option<&ChannelTransition> {
76 self.background_color.as_ref().or(self.all.as_ref())
77 }
78 pub fn for_size(&self) -> Option<&ChannelTransition> {
80 self.size.as_ref().or(self.all.as_ref())
81 }
82 pub fn for_scroll(&self) -> Option<&ChannelTransition> {
84 self.scroll.as_ref().or(self.all.as_ref())
85 }
86}
87
88#[derive(Debug, Clone, Deserialize)]
93#[serde(rename_all = "camelCase")]
94pub struct ChannelTransition {
95 pub duration: Option<WireTime>,
97 #[serde(default)]
98 pub easing: Easing,
99 #[serde(default)]
101 pub delay: WireTime,
102 pub stiffness: Option<f32>,
104 pub damping: Option<f32>,
105 #[serde(default = "default_mass")]
106 pub mass: f32,
107}
108
109fn default_mass() -> f32 {
110 1.0
111}
112
113impl ChannelTransition {
114 fn to_driver(&self, to: f32) -> Driver {
118 if self.stiffness.is_some() || self.damping.is_some() {
119 Driver::Spring {
120 to,
121 stiffness: self.stiffness.unwrap_or(100.0),
122 damping: self.damping.unwrap_or(10.0),
123 mass: self.mass,
124 }
125 } else {
126 let timing = Driver::Timing {
127 to,
128 duration: self.duration.map(WireTime::seconds).unwrap_or(0.3),
129 easing: self.easing,
130 };
131 let delay = self.delay.seconds();
132 if delay > 0.0 {
133 Driver::Delay {
134 delay,
135 animation: Box::new(timing),
136 }
137 } else {
138 timing
139 }
140 }
141 }
142}
143
144#[derive(Component, Debug, Clone, Default)]
150pub struct TransitionInput {
151 pub spec: Transition,
152 pub translate_x: Option<Length>,
153 pub translate_y: Option<Length>,
154 pub scale: Option<f32>,
155 pub scale_x: Option<f32>,
156 pub scale_y: Option<f32>,
157 pub rotate: Option<f32>,
158 pub opacity: Option<f32>,
159 pub background_color: Option<[f32; 4]>,
162 pub width: Option<Length>,
164 pub height: Option<Length>,
165 pub max_width: Option<Length>,
166 pub max_height: Option<Length>,
167}
168
169impl TransitionInput {
170 fn from_style(style: &Style) -> Option<Self> {
172 let spec = style.transition.clone()?;
173 let t = style.transform.unwrap_or_default();
174 Some(Self {
175 spec,
176 translate_x: t.translate_x,
177 translate_y: t.translate_y,
178 scale: t.scale,
179 scale_x: t.scale_x,
180 scale_y: t.scale_y,
181 rotate: t.rotate.map(crate::protocol::Angle::radians),
182 opacity: style.opacity,
183 background_color: style
184 .background_color
185 .as_deref()
186 .map(|hex| color_to_rgba(parse_color(hex))),
187 width: style.width,
188 height: style.height,
189 max_width: style.max_width,
190 max_height: style.max_height,
191 })
192 }
193}
194
195#[derive(Component, Default)]
200#[require(UiTransform)]
201pub struct TransitionState {
202 translate_x: ProgressChannel<Length>,
203 translate_y: ProgressChannel<Length>,
204 scale: Channel,
205 scale_x: Channel,
206 scale_y: Channel,
207 rotate: Channel,
208 opacity: Channel,
209 color: ProgressChannel<[f32; 4]>,
210 width: ProgressChannel<Length>,
211 height: ProgressChannel<Length>,
212 max_width: ProgressChannel<Length>,
213 max_height: ProgressChannel<Length>,
214 initialized: bool,
215}
216
217#[derive(Default)]
219struct Channel {
220 current: f32,
221 target: f32,
222 runner: Option<Runner>,
223}
224
225impl Channel {
226 fn init(&mut self, value: f32) {
229 self.current = value;
230 self.target = value;
231 self.runner = None;
232 }
233
234 fn drive(&mut self, target: f32, spec: Option<&ChannelTransition>, dt: f32) -> f32 {
237 if target != self.target {
238 self.target = target;
239 match spec {
240 Some(s) => self.runner = Some(build_runner(&s.to_driver(target), self.current)),
241 None => {
242 self.current = target;
243 self.runner = None;
244 }
245 }
246 }
247 if let Some(r) = self.runner.as_mut() {
248 let (v, done) = r.step(dt);
249 self.current = v;
250 if done {
251 self.runner = None;
252 }
253 }
254 self.current
255 }
256}
257
258#[derive(Default)]
266struct ProgressChannel<T> {
267 current: T,
268 target: T,
269 start: T,
270 runner: Option<Runner>,
271}
272
273impl<T: Lerp + PartialEq> ProgressChannel<T> {
274 fn init(&mut self, value: T) {
277 self.current = value;
278 self.target = value;
279 self.runner = None;
280 }
281
282 fn drive(&mut self, target: T, spec: Option<&ChannelTransition>, dt: f32) -> T {
285 if target != self.target {
286 self.target = target;
287 match spec {
288 Some(s) => {
289 self.start = self.current;
290 self.runner = Some(build_runner(&s.to_driver(1.0), 0.0));
291 }
292 None => {
293 self.current = target;
294 self.runner = None;
295 }
296 }
297 }
298 if let Some(r) = self.runner.as_mut() {
299 let (p, done) = r.step(dt);
300 self.current = self.start.lerp(self.target, p);
301 if done {
302 self.current = self.target;
303 self.runner = None;
304 }
305 }
306 self.current
307 }
308}
309
310impl Lerp for Length {
313 fn lerp(self, other: Self, t: f32) -> Self {
314 use Length::*;
315 let lerp = |x: f32, y: f32| x + (y - x) * t;
316 match (self, other) {
317 (Px(x), Px(y)) => Px(lerp(x, y)),
318 (Percent(x), Percent(y)) => Percent(lerp(x, y)),
319 (Vw(x), Vw(y)) => Vw(lerp(x, y)),
320 (Vh(x), Vh(y)) => Vh(lerp(x, y)),
321 (VMin(x), VMin(y)) => VMin(lerp(x, y)),
322 (VMax(x), VMax(y)) => VMax(lerp(x, y)),
323 _ => other,
324 }
325 }
326}
327
328#[derive(Component, Debug, Clone)]
334pub struct ScrollTransitionInput(pub ChannelTransition);
335
336#[derive(Component, Default)]
342pub struct ScrollTransitionState {
343 pub(crate) target: Vec2,
345 x: Channel,
346 y: Channel,
347 initialized: bool,
348}
349
350pub fn apply_scroll_transition(ec: &mut EntityCommands, style: &Option<Style>) {
355 match style
356 .as_ref()
357 .and_then(|s| s.transition.as_ref())
358 .and_then(|t| t.for_scroll())
359 {
360 Some(spec) => {
361 ec.insert(ScrollTransitionInput(spec.clone()));
362 ec.insert_if_new(ScrollTransitionState::default());
363 }
364 None => {
365 ec.remove::<ScrollTransitionInput>();
366 ec.remove::<ScrollTransitionState>();
367 }
368 }
369}
370
371pub fn drive_scroll_transition(
377 time: Res<Time>,
378 mut query: Query<(
379 &ScrollTransitionInput,
380 &mut ScrollTransitionState,
381 &mut ScrollPosition,
382 )>,
383) {
384 let dt = time.delta_secs();
385 for (input, mut state, mut pos) in &mut query {
386 if !state.initialized {
389 state.x.init(pos.0.x);
390 state.y.init(pos.0.y);
391 state.target = pos.0;
392 state.initialized = true;
393 }
394 let spec = &input.0;
395 let target = state.target;
396 let nx = state.x.drive(target.x, Some(spec), dt);
397 let ny = state.y.drive(target.y, Some(spec), dt);
398 if pos.0.x != nx || pos.0.y != ny {
400 pos.0 = Vec2::new(nx, ny);
401 }
402 }
403}
404
405pub fn apply_transition(ec: &mut EntityCommands, style: &Option<Style>) {
410 match style.as_ref().and_then(TransitionInput::from_style) {
411 Some(input) => {
412 ec.insert(input);
413 ec.insert_if_new(TransitionState::default());
415 }
416 None => {
417 ec.remove::<TransitionInput>();
418 ec.remove::<TransitionState>();
419 }
420 }
421}
422
423#[allow(clippy::type_complexity)]
428pub fn drive_transitions(
429 time: Res<Time>,
430 mut commands: Commands,
431 mut query: Query<(
432 Entity,
433 &TransitionInput,
434 &mut TransitionState,
435 &mut UiTransform,
436 Option<&mut BackgroundColor>,
437 Option<&mut TextColor>,
438 Option<&mut ImageNode>,
439 Option<&mut Node>,
440 Option<&AnimatedNode>,
441 )>,
442) {
443 let dt = time.delta_secs();
444 for (entity, input, mut state, mut transform, bg, text_color, image, node, anim) in &mut query {
445 if !state.initialized {
448 state
449 .translate_x
450 .init(input.translate_x.unwrap_or(Length::Px(0.0)));
451 state
452 .translate_y
453 .init(input.translate_y.unwrap_or(Length::Px(0.0)));
454 state.scale.init(input.scale.unwrap_or(1.0));
455 state.scale_x.init(input.scale_x.unwrap_or(1.0));
456 state.scale_y.init(input.scale_y.unwrap_or(1.0));
457 state.rotate.init(input.rotate.unwrap_or(0.0));
458 state.opacity.init(input.opacity.unwrap_or(1.0));
459 if let Some(c) = input.background_color {
460 state.color.init(c);
461 }
462 state.width.init(input.width.unwrap_or(Length::Auto));
463 state.height.init(input.height.unwrap_or(Length::Auto));
464 state
465 .max_width
466 .init(input.max_width.unwrap_or(Length::Auto));
467 state
468 .max_height
469 .init(input.max_height.unwrap_or(Length::Auto));
470 state.initialized = true;
471 }
472
473 let skip_transform = anim.is_some_and(|a| a.0.has_transform());
475 let skip_opacity = anim.is_some_and(|a| a.0.contains(AnimatableProperty::Opacity));
476 let skip_bg = anim.is_some_and(|a| a.0.contains(AnimatableProperty::BackgroundColor));
477
478 if input.spec.for_transform().is_some() && !skip_transform {
483 let s = input.spec.for_transform();
484 let tx = input
485 .translate_x
486 .map(|t| length_to_val(state.translate_x.drive(t, s, dt)));
487 let ty = input
488 .translate_y
489 .map(|t| length_to_val(state.translate_y.drive(t, s, dt)));
490 let sc = input.scale.map(|t| state.scale.drive(t, s, dt));
491 let scx = input.scale_x.map(|t| state.scale_x.drive(t, s, dt));
492 let scy = input.scale_y.map(|t| state.scale_y.drive(t, s, dt));
493 let rot = input.rotate.map(|t| state.rotate.drive(t, s, dt));
494 let new = build_ui_transform(tx, ty, sc, scx, scy, rot);
497 if *transform != new {
498 *transform = new;
499 }
500 }
501
502 let mut bg = bg;
503
504 let alpha = if !skip_opacity && let Some(target) = input.opacity {
509 Some(state.opacity.drive(target, input.spec.for_opacity(), dt))
510 } else {
511 None
512 };
513
514 if !skip_bg && let Some(target) = input.background_color {
515 let mut rgba = state.color.drive(target, input.spec.for_background(), dt);
516 if let Some(a) = alpha {
517 rgba[3] = a;
518 }
519 let color = rgba_to_color(rgba);
520 match &mut bg {
521 Some(c) if c.0 != color => c.0 = color,
522 Some(_) => {}
523 None => {
524 commands.entity(entity).insert(BackgroundColor(color));
525 }
526 }
527 }
528
529 if let Some(alpha) = alpha {
532 if let Some(c) = &mut bg
533 && c.0.alpha() != alpha
534 {
535 c.0 = c.0.with_alpha(alpha);
536 }
537 if let Some(mut tc) = text_color
538 && tc.0.alpha() != alpha
539 {
540 tc.0 = tc.0.with_alpha(alpha);
541 }
542 if let Some(mut img) = image
543 && img.color.alpha() != alpha
544 {
545 img.color = img.color.with_alpha(alpha);
546 }
547 }
548
549 if input.spec.for_size().is_some()
556 && let Some(mut node) = node
557 {
558 let s = input.spec.for_size();
559 if let Some(t) = input.width {
560 let v = length_to_val(state.width.drive(t, s, dt));
561 if node.width != v {
562 node.width = v;
563 }
564 }
565 if let Some(t) = input.height {
566 let v = length_to_val(state.height.drive(t, s, dt));
567 if node.height != v {
568 node.height = v;
569 }
570 }
571 if let Some(t) = input.max_width {
572 let v = length_to_val(state.max_width.drive(t, s, dt));
573 if node.max_width != v {
574 node.max_width = v;
575 }
576 }
577 if let Some(t) = input.max_height {
578 let v = length_to_val(state.max_height.drive(t, s, dt));
579 if node.max_height != v {
580 node.max_height = v;
581 }
582 }
583 }
584 }
585}
586
587fn color_to_rgba(color: Color) -> [f32; 4] {
588 let s = color.to_srgba();
589 [s.red, s.green, s.blue, s.alpha]
590}
591
592fn rgba_to_color(rgba: [f32; 4]) -> Color {
593 Color::srgba(rgba[0], rgba[1], rgba[2], rgba[3])
594}
595
596#[cfg(test)]
597mod tests {
598 use super::*;
599 use crate::animations::AnimatedBindings;
600 use std::time::Duration;
601
602 fn timing(duration: f32, easing: Easing) -> ChannelTransition {
603 ChannelTransition {
604 duration: Some(WireTime::from_secs(duration)),
605 easing,
606 delay: WireTime::from_secs(0.0),
607 stiffness: None,
608 damping: None,
609 mass: 1.0,
610 }
611 }
612
613 fn parse<T: serde::de::DeserializeOwned>(json: serde_json::Value) -> T {
614 serde_json::from_value(json).expect("valid json")
615 }
616
617 #[test]
618 fn channel_resolution_falls_back_to_all() {
619 let t: Transition = parse(serde_json::json!({
620 "all": { "duration": 100 },
621 "opacity": { "duration": 200 },
622 }));
623 let secs = |c: &ChannelTransition| c.duration.map(WireTime::seconds);
626 assert!(t.for_opacity().is_some());
627 assert_eq!(secs(t.for_opacity().unwrap()), Some(0.2));
628 assert_eq!(secs(t.for_transform().unwrap()), Some(0.1));
629 assert_eq!(secs(t.for_background().unwrap()), Some(0.1));
630
631 let t: Transition = parse(serde_json::json!({ "opacity": { "duration": 50 } }));
633 assert!(t.for_transform().is_none());
634 assert!(t.for_opacity().is_some());
635 }
636
637 #[test]
638 fn to_driver_selects_spring_or_timing() {
639 let spring = ChannelTransition {
640 duration: None,
641 easing: Easing::Linear,
642 delay: WireTime::from_secs(0.0),
643 stiffness: Some(120.0),
644 damping: Some(14.0),
645 mass: 1.0,
646 };
647 assert!(matches!(spring.to_driver(1.0), Driver::Spring { .. }));
648 assert!(matches!(
649 timing(0.3, Easing::Linear).to_driver(1.0),
650 Driver::Timing { .. }
651 ));
652 let delayed = ChannelTransition {
654 delay: WireTime::from_secs(0.2),
655 ..timing(0.3, Easing::Linear)
656 };
657 assert!(matches!(delayed.to_driver(1.0), Driver::Delay { .. }));
658 }
659
660 #[test]
661 fn channel_snaps_without_spec_and_eases_with_one() {
662 let mut ch = Channel::default();
664 ch.init(1.0);
665 assert_eq!(ch.drive(0.5, None, 0.016), 0.5);
666
667 let mut ch = Channel::default();
669 ch.init(1.0);
670 let spec = timing(1.0, Easing::Linear);
671 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}");
674 let v = ch.drive(0.0, Some(&spec), 0.5);
675 assert!((v - 0.0).abs() < 1e-3, "end expected 0, got {v}");
676 assert!(ch.runner.is_none(), "runner dropped once finished");
677 }
678
679 #[test]
680 fn color_channel_lerps_to_target() {
681 let mut c = ProgressChannel::<[f32; 4]>::default();
682 c.init([0.0, 0.0, 0.0, 1.0]);
683 let spec = timing(1.0, Easing::Linear);
684 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);
686 assert!((mid[0] - 0.5).abs() < 1e-3);
687 assert!((mid[1] - 0.25).abs() < 1e-3);
688 assert!((mid[2] - 0.0).abs() < 1e-3);
689 }
690
691 fn drive_world() -> (World, Schedule) {
693 let mut world = World::new();
694 world.insert_resource(Time::<()>::default());
695 let mut schedule = Schedule::default();
696 schedule.add_systems(drive_transitions);
697 (world, schedule)
698 }
699
700 fn advance(world: &mut World, secs: f32) {
701 world
702 .resource_mut::<Time>()
703 .advance_by(Duration::from_secs_f32(secs));
704 }
705
706 #[test]
707 fn system_eases_scale_on_press_then_release() {
708 let (mut world, mut schedule) = drive_world();
709 let spec = Transition {
710 transform: Some(timing(1.0, Easing::Linear)),
711 ..Default::default()
712 };
713 let e = world
714 .spawn((
715 TransitionInput {
716 spec: spec.clone(),
717 scale: Some(1.0),
718 ..Default::default()
719 },
720 TransitionState::default(),
721 UiTransform::default(),
722 ))
723 .id();
724
725 schedule.run(&mut world);
727 assert_eq!(world.entity(e).get::<UiTransform>().unwrap().scale.x, 1.0);
728
729 world
731 .entity_mut(e)
732 .get_mut::<TransitionInput>()
733 .unwrap()
734 .scale = Some(0.95);
735 advance(&mut world, 0.5);
736 schedule.run(&mut world);
737 let sx = world.entity(e).get::<UiTransform>().unwrap().scale.x;
738 assert!(
739 (sx - 0.975).abs() < 1e-2,
740 "mid-press expected ~0.975, got {sx}"
741 );
742
743 advance(&mut world, 0.5);
745 schedule.run(&mut world);
746 let sx = world.entity(e).get::<UiTransform>().unwrap().scale.x;
747 assert!((sx - 0.95).abs() < 1e-3, "pressed expected 0.95, got {sx}");
748
749 world
751 .entity_mut(e)
752 .get_mut::<TransitionInput>()
753 .unwrap()
754 .scale = Some(1.0);
755 advance(&mut world, 0.5);
756 schedule.run(&mut world);
757 let sx = world.entity(e).get::<UiTransform>().unwrap().scale.x;
758 assert!(
759 (sx - 0.975).abs() < 1e-2,
760 "mid-release expected ~0.975, got {sx}"
761 );
762 }
763
764 #[test]
765 fn system_eases_percent_translate() {
766 let (mut world, mut schedule) = drive_world();
767 let spec = Transition {
768 transform: Some(timing(1.0, Easing::Linear)),
769 ..Default::default()
770 };
771 let e = world
772 .spawn((
773 TransitionInput {
774 spec,
775 translate_x: Some(Length::Percent(0.0)),
776 ..Default::default()
777 },
778 TransitionState::default(),
779 UiTransform::default(),
780 ))
781 .id();
782
783 schedule.run(&mut world);
785 assert_eq!(
786 world.entity(e).get::<UiTransform>().unwrap().translation.x,
787 Val::Percent(0.0)
788 );
789
790 world
793 .entity_mut(e)
794 .get_mut::<TransitionInput>()
795 .unwrap()
796 .translate_x = Some(Length::Percent(100.0));
797 advance(&mut world, 0.5);
798 schedule.run(&mut world);
799 let tx = world.entity(e).get::<UiTransform>().unwrap().translation.x;
800 assert!(
801 matches!(tx, Val::Percent(v) if (v - 50.0).abs() < 1.0),
802 "mid expected ~50%, got {tx:?}"
803 );
804
805 advance(&mut world, 0.5);
806 schedule.run(&mut world);
807 assert_eq!(
808 world.entity(e).get::<UiTransform>().unwrap().translation.x,
809 Val::Percent(100.0)
810 );
811 }
812
813 #[test]
814 fn animated_style_channel_wins_over_transition() {
815 let (mut world, mut schedule) = drive_world();
816 let spec = Transition {
817 transform: Some(timing(1.0, Easing::Linear)),
818 ..Default::default()
819 };
820 let bindings: AnimatedBindings = serde_json::from_value(serde_json::json!({
823 "scale": { "type": "shared", "id": 1 }
824 }))
825 .unwrap();
826 let e = world
827 .spawn((
828 TransitionInput {
829 spec,
830 scale: Some(1.0),
831 ..Default::default()
832 },
833 TransitionState::default(),
834 UiTransform::from_scale(Vec2::splat(2.0)), AnimatedNode(bindings),
836 ))
837 .id();
838
839 schedule.run(&mut world);
840 world
841 .entity_mut(e)
842 .get_mut::<TransitionInput>()
843 .unwrap()
844 .scale = Some(0.95);
845 advance(&mut world, 0.5);
846 schedule.run(&mut world);
847 assert_eq!(world.entity(e).get::<UiTransform>().unwrap().scale.x, 2.0);
849 }
850
851 #[test]
855 fn settled_transition_does_not_dirty_components() {
856 #[derive(Resource, Default)]
857 struct Dirty(usize);
858
859 let (mut world, mut schedule) = drive_world();
860 world.init_resource::<Dirty>();
861 let spec = Transition {
862 transform: Some(timing(0.2, Easing::Linear)),
863 background_color: Some(timing(0.2, Easing::Linear)),
864 opacity: Some(timing(0.2, Easing::Linear)),
865 ..Default::default()
866 };
867 let e = world
868 .spawn((
869 TransitionInput {
870 spec,
871 scale: Some(1.0),
872 opacity: Some(0.5),
875 background_color: Some([1.0, 0.0, 0.0, 1.0]),
876 ..Default::default()
877 },
878 TransitionState::default(),
879 UiTransform::default(),
880 BackgroundColor(Color::WHITE),
881 ))
882 .id();
883
884 type AnyTargetChanged = Or<(Changed<UiTransform>, Changed<BackgroundColor>)>;
885
886 let mut detect = Schedule::default();
887 detect.add_systems(|q: Query<(), AnyTargetChanged>, mut dirty: ResMut<Dirty>| {
888 dirty.0 = q.iter().count();
889 });
890
891 schedule.run(&mut world);
893 world
894 .entity_mut(e)
895 .get_mut::<TransitionInput>()
896 .unwrap()
897 .scale = Some(0.9);
898 advance(&mut world, 0.5);
899 schedule.run(&mut world);
900 detect.run(&mut world); advance(&mut world, 0.5);
903 schedule.run(&mut world);
904 detect.run(&mut world);
905 assert_eq!(
906 world.resource::<Dirty>().0,
907 0,
908 "a settled transition must not dirty anything"
909 );
910 }
911
912 #[test]
913 fn lerp_length_same_unit_else_snaps() {
914 assert_eq!(Length::Px(0.0).lerp(Length::Px(10.0), 0.5), Length::Px(5.0));
915 assert_eq!(
916 Length::Percent(0.0).lerp(Length::Percent(100.0), 0.25),
917 Length::Percent(25.0)
918 );
919 assert_eq!(Length::Auto.lerp(Length::Px(10.0), 0.5), Length::Px(10.0));
921 assert_eq!(
922 Length::Px(0.0).lerp(Length::Percent(10.0), 0.5),
923 Length::Percent(10.0)
924 );
925 }
926
927 fn px(l: Length) -> f32 {
928 match l {
929 Length::Px(v) => v,
930 other => panic!("expected Px, got {other:?}"),
931 }
932 }
933
934 #[test]
935 fn length_channel_eases_then_idles() {
936 let mut ch = ProgressChannel::<Length>::default();
937 ch.init(Length::Px(0.0));
938 let spec = timing(1.0, Easing::Linear);
939 assert!((px(ch.drive(Length::Px(100.0), Some(&spec), 0.0)) - 0.0).abs() < 1e-3);
941 assert!((px(ch.drive(Length::Px(100.0), Some(&spec), 0.5)) - 50.0).abs() < 1e-3);
942 assert!((px(ch.drive(Length::Px(100.0), Some(&spec), 0.5)) - 100.0).abs() < 1e-3);
943 assert!(ch.runner.is_none(), "runner dropped once settled");
946 assert_eq!(
947 ch.drive(Length::Px(100.0), Some(&spec), 0.5),
948 Length::Px(100.0)
949 );
950 }
951
952 #[test]
953 fn system_eases_max_height_layout() {
954 let (mut world, mut schedule) = drive_world();
955 let spec = Transition {
956 size: Some(timing(1.0, Easing::Linear)),
957 ..Default::default()
958 };
959 let e = world
960 .spawn((
961 TransitionInput {
962 spec,
963 max_height: Some(Length::Px(120.0)),
964 ..Default::default()
965 },
966 TransitionState::default(),
967 Node::default(),
968 UiTransform::default(),
969 ))
970 .id();
971
972 schedule.run(&mut world);
974
975 world
977 .entity_mut(e)
978 .get_mut::<TransitionInput>()
979 .unwrap()
980 .max_height = Some(Length::Px(0.0));
981 advance(&mut world, 0.5);
982 schedule.run(&mut world);
983 let mh = world.entity(e).get::<Node>().unwrap().max_height;
984 assert!(
985 matches!(mh, Val::Px(v) if (v - 60.0).abs() < 1.0),
986 "mid expected ~60px, got {mh:?}"
987 );
988
989 advance(&mut world, 0.5);
990 schedule.run(&mut world);
991 let mh = world.entity(e).get::<Node>().unwrap().max_height;
992 assert!(
993 matches!(mh, Val::Px(v) if v.abs() < 1e-3),
994 "settled expected 0px, got {mh:?}"
995 );
996 }
997
998 #[test]
1001 fn system_eases_scroll_toward_target() {
1002 let mut world = World::new();
1003 world.insert_resource(Time::<()>::default());
1004 let mut schedule = Schedule::default();
1005 schedule.add_systems(drive_scroll_transition);
1006
1007 let e = world
1008 .spawn((
1009 ScrollTransitionInput(timing(1.0, Easing::Linear)),
1010 ScrollTransitionState::default(),
1011 ScrollPosition::default(),
1012 ))
1013 .id();
1014
1015 schedule.run(&mut world);
1017 assert_eq!(
1018 world.entity(e).get::<ScrollPosition>().unwrap().0,
1019 Vec2::ZERO
1020 );
1021
1022 world
1024 .entity_mut(e)
1025 .get_mut::<ScrollTransitionState>()
1026 .unwrap()
1027 .target = Vec2::new(0.0, 100.0);
1028 advance(&mut world, 0.5);
1029 schedule.run(&mut world);
1030 let y = world.entity(e).get::<ScrollPosition>().unwrap().0.y;
1031 assert!((y - 50.0).abs() < 1.0, "mid-ease expected ~50, got {y}");
1032
1033 advance(&mut world, 0.5);
1035 schedule.run(&mut world);
1036 assert_eq!(
1037 world.entity(e).get::<ScrollPosition>().unwrap().0,
1038 Vec2::new(0.0, 100.0)
1039 );
1040 }
1041}