use crate::animations::{
AnimatableProperty, AnimatedNode, Driver, Easing, Lerp, Runner, build_runner,
build_ui_transform,
};
use bevy::ecs::query::QueryData;
use bevy::prelude::*;
use bevy::ui::{ScrollPosition, UiTransform};
use serde::Deserialize;
use crate::protocol::{AnimatableField, Length, Style, Time as WireTime};
use crate::ui_map::{length_to_val, parse_color};
mod transform3d;
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Transition {
pub all: Option<ChannelTransition>,
pub transform: Option<ChannelTransition>,
pub opacity: Option<ChannelTransition>,
pub background_color: Option<ChannelTransition>,
pub size: Option<ChannelTransition>,
pub scroll: Option<ChannelTransition>,
pub filter: Option<ChannelTransition>,
pub backdrop_filter: Option<ChannelTransition>,
pub transform3d: Option<ChannelTransition>,
}
impl Transition {
pub fn for_transform(&self) -> Option<&ChannelTransition> {
self.transform.as_ref().or(self.all.as_ref())
}
pub fn for_opacity(&self) -> Option<&ChannelTransition> {
self.opacity.as_ref().or(self.all.as_ref())
}
pub fn for_background(&self) -> Option<&ChannelTransition> {
self.background_color.as_ref().or(self.all.as_ref())
}
pub fn for_size(&self) -> Option<&ChannelTransition> {
self.size.as_ref().or(self.all.as_ref())
}
pub fn for_scroll(&self) -> Option<&ChannelTransition> {
self.scroll.as_ref().or(self.all.as_ref())
}
pub fn for_filter(&self) -> Option<&ChannelTransition> {
self.filter.as_ref().or(self.all.as_ref())
}
pub fn for_backdrop_filter(&self) -> Option<&ChannelTransition> {
self.backdrop_filter.as_ref().or(self.all.as_ref())
}
pub fn for_transform3d(&self) -> Option<&ChannelTransition> {
self.transform3d.as_ref().or(self.all.as_ref())
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChannelTransition {
pub duration: Option<WireTime>,
#[serde(default)]
pub easing: Easing,
#[serde(default)]
pub delay: WireTime,
pub stiffness: Option<f32>,
pub damping: Option<f32>,
#[serde(default = "default_mass")]
pub mass: f32,
}
fn default_mass() -> f32 {
1.0
}
impl ChannelTransition {
fn to_driver(&self, to: f32) -> Driver {
if self.stiffness.is_some() || self.damping.is_some() {
Driver::Spring {
to,
stiffness: self.stiffness.unwrap_or(100.0),
damping: self.damping.unwrap_or(10.0),
mass: self.mass,
}
} else {
let timing = Driver::Timing {
to,
duration: self.duration.map(WireTime::seconds).unwrap_or(0.3),
easing: self.easing,
};
let delay = self.delay.seconds();
if delay > 0.0 {
Driver::Delay {
delay,
animation: Box::new(timing),
}
} else {
timing
}
}
}
}
#[derive(Component, Debug, Clone, Default)]
pub struct TransitionInput {
pub spec: Transition,
pub translate_x: Option<Length>,
pub translate_y: Option<Length>,
pub scale: Option<f32>,
pub scale_x: Option<f32>,
pub scale_y: Option<f32>,
pub rotate: Option<f32>,
pub opacity: Option<f32>,
pub background_color: Option<[f32; 4]>,
pub width: Option<Length>,
pub height: Option<Length>,
pub max_width: Option<Length>,
pub max_height: Option<Length>,
pub transform3d: Option<crate::protocol::Transform3d>,
}
impl TransitionInput {
fn from_style(style: &Style) -> Option<Self> {
let spec = style.transition.clone()?;
let t = style.transform.clone().unwrap_or_default();
Some(Self {
spec,
translate_x: t.translate_x.static_val(),
translate_y: t.translate_y.static_val(),
scale: t.scale.static_val(),
scale_x: t.scale_x.static_val(),
scale_y: t.scale_y.static_val(),
rotate: t.rotate.static_val().map(crate::protocol::Angle::radians),
opacity: style.opacity.static_val(),
background_color: style
.background_color
.static_ref()
.map(|hex| color_to_rgba(parse_color(hex))),
width: style.width.static_val(),
height: style.height.static_val(),
max_width: style.max_width.static_val(),
max_height: style.max_height.static_val(),
transform3d: style.transform3d.clone(),
})
}
}
#[derive(Component, Default)]
#[require(UiTransform)]
pub struct TransitionState {
translate_x: ProgressChannel<Length>,
translate_y: ProgressChannel<Length>,
scale: Channel,
scale_x: Channel,
scale_y: Channel,
rotate: Channel,
opacity: Channel,
color: ProgressChannel<[f32; 4]>,
width: ProgressChannel<Length>,
height: ProgressChannel<Length>,
max_width: ProgressChannel<Length>,
max_height: ProgressChannel<Length>,
filter: FilterChannel,
backdrop_filter: FilterChannel,
transform3d: transform3d::Transform3dChannels,
initialized: bool,
}
#[derive(Default)]
struct FilterChannel {
wire: crate::filters::FilterChain,
current: Vec<crate::filters::ResolvedFilterPass>,
ease: Option<ActiveFilterEase>,
}
struct ActiveFilterEase {
runner: Runner,
ease: crate::filters::FilterEase,
}
impl FilterChannel {
fn drive(
&mut self,
input: Option<&crate::filters::FilterChain>,
mut resolved: Option<Mut<crate::filters::ResolvedFilterChain>>,
spec: Option<&ChannelTransition>,
registry: Option<&crate::filters::FilterRegistry>,
assets: Option<&AssetServer>,
dt: f32,
) -> bool {
let retargeted = match input {
Some(fi) => *fi != self.wire,
None => !self.wire.0.is_empty(),
};
if retargeted {
let to_wire = input.cloned().unwrap_or_default();
let from_wire = std::mem::replace(&mut self.wire, to_wire);
match (spec, resolved.as_deref()) {
(Some(spec), Some(chain)) if !self.wire.0.is_empty() => {
self.ease = Some(ActiveFilterEase {
runner: build_runner(&spec.to_driver(1.0), 0.0),
ease: crate::filters::plan_filter_ease(
&from_wire,
&self.wire,
self.current.clone(),
chain.passes.clone(),
registry,
assets,
chain.scale,
),
});
}
_ => {
self.current = resolved
.as_deref()
.map(|c| c.passes.clone())
.unwrap_or_default();
self.ease = None;
}
}
}
let mut wrote = false;
if let Some(mut active) = self.ease.take() {
match resolved.as_mut() {
Some(resolved) => {
let (p, done) = active.runner.step(dt);
let new = if done {
active.ease.settle().to_vec()
} else {
active.ease.sample(p)
};
if resolved.passes != new {
let chain = &mut **resolved;
chain.passes = new.clone();
chain.version = chain.version.wrapping_add(1);
wrote = true;
}
self.current = new;
if !done {
self.ease = Some(active);
}
}
None => {
self.current = Vec::new();
}
}
}
wrote
}
}
#[derive(Default)]
struct Channel {
current: f32,
target: f32,
runner: Option<Runner>,
}
impl Channel {
fn init(&mut self, value: f32) {
self.current = value;
self.target = value;
self.runner = None;
}
fn drive(&mut self, target: f32, spec: Option<&ChannelTransition>, dt: f32) -> f32 {
if target != self.target {
self.target = target;
match spec {
Some(s) => self.runner = Some(build_runner(&s.to_driver(target), self.current)),
None => {
self.current = target;
self.runner = None;
}
}
}
if let Some(r) = self.runner.as_mut() {
let (v, done) = r.step(dt);
self.current = v;
if done {
self.runner = None;
}
}
self.current
}
}
#[derive(Default)]
struct ProgressChannel<T> {
current: T,
target: T,
start: T,
runner: Option<Runner>,
}
impl<T: Lerp + PartialEq> ProgressChannel<T> {
fn init(&mut self, value: T) {
self.current = value;
self.target = value;
self.runner = None;
}
fn drive(&mut self, target: T, spec: Option<&ChannelTransition>, dt: f32) -> T {
if target != self.target {
self.target = target;
match spec {
Some(s) => {
self.start = self.current;
self.runner = Some(build_runner(&s.to_driver(1.0), 0.0));
}
None => {
self.current = target;
self.runner = None;
}
}
}
if let Some(r) = self.runner.as_mut() {
let (p, done) = r.step(dt);
self.current = self.start.lerp(self.target, p);
if done {
self.current = self.target;
self.runner = None;
}
}
self.current
}
}
impl Lerp for Length {
fn lerp(self, other: Self, t: f32) -> Self {
use Length::*;
let lerp = |x: f32, y: f32| x + (y - x) * t;
match (self, other) {
(Px(x), Px(y)) => Px(lerp(x, y)),
(Percent(x), Percent(y)) => Percent(lerp(x, y)),
(Vw(x), Vw(y)) => Vw(lerp(x, y)),
(Vh(x), Vh(y)) => Vh(lerp(x, y)),
(VMin(x), VMin(y)) => VMin(lerp(x, y)),
(VMax(x), VMax(y)) => VMax(lerp(x, y)),
_ => other,
}
}
}
#[derive(Component, Debug, Clone)]
pub struct ScrollTransitionInput(pub ChannelTransition);
#[derive(Component, Default)]
pub struct ScrollTransitionState {
pub(crate) target: Vec2,
x: Channel,
y: Channel,
initialized: bool,
}
impl ScrollTransitionState {
pub(crate) fn snap_to(&mut self, value: Vec2) {
self.target = value;
self.x.init(value.x);
self.y.init(value.y);
self.initialized = true;
}
}
pub fn apply_scroll_transition(ec: &mut EntityCommands, style: &Option<Style>) {
match style
.as_ref()
.and_then(|s| s.transition.as_ref())
.and_then(|t| t.for_scroll())
{
Some(spec) => {
ec.insert(ScrollTransitionInput(spec.clone()));
ec.insert_if_new(ScrollTransitionState::default());
}
None => {
ec.remove::<ScrollTransitionInput>();
ec.remove::<ScrollTransitionState>();
}
}
}
pub fn drive_scroll_transition(
time: Res<Time>,
mut query: Query<(
&ScrollTransitionInput,
&mut ScrollTransitionState,
&mut ScrollPosition,
)>,
) {
let dt = time.delta_secs();
for (input, mut state, mut pos) in &mut query {
if !state.initialized {
state.x.init(pos.0.x);
state.y.init(pos.0.y);
state.target = pos.0;
state.initialized = true;
}
let current = Vec2::new(state.x.current, state.y.current);
if pos.0 != current {
state.snap_to(pos.0);
continue;
}
let spec = &input.0;
let target = state.target;
let nx = state.x.drive(target.x, Some(spec), dt);
let ny = state.y.drive(target.y, Some(spec), dt);
if pos.0.x != nx || pos.0.y != ny {
pos.0 = Vec2::new(nx, ny);
}
}
}
pub fn apply_transition(ec: &mut EntityCommands, style: &Option<Style>) {
match style.as_ref().and_then(TransitionInput::from_style) {
Some(input) => {
ec.insert(input);
ec.insert_if_new(TransitionState::default());
}
None => {
ec.remove::<TransitionInput>();
ec.remove::<TransitionState>();
}
}
}
#[derive(QueryData)]
#[query_data(mutable)]
pub struct TransitionTargets {
transform: &'static mut UiTransform,
bg: Option<&'static mut BackgroundColor>,
text: Option<&'static mut TextColor>,
image: Option<&'static mut ImageNode>,
node: Option<&'static mut Node>,
anim: Option<&'static AnimatedNode>,
promoted: Option<&'static crate::layer::PromotedLayer>,
layer_alpha: Option<&'static mut crate::layer::LayerGroupAlpha>,
filter_input: Option<&'static crate::filters::FilterInput>,
resolved_filter: Option<&'static mut crate::filters::ResolvedFilterChain>,
backdrop_input: Option<&'static crate::filters::BackdropInput>,
resolved_backdrop: Option<&'static mut crate::filters::ResolvedBackdropChain>,
transform3d: Option<&'static mut crate::layer::transform3d::LayerTransform3d>,
}
pub fn drive_transitions(
time: Res<Time>,
mut commands: Commands,
mut dirt: ResMut<crate::layer::LayerContentDirt>,
filter_registry: Option<Res<crate::filters::FilterRegistry>>,
assets: Option<Res<AssetServer>>,
mut query: Query<(
Entity,
&TransitionInput,
&mut TransitionState,
TransitionTargets,
)>,
) {
let dt = time.delta_secs();
for (entity, input, mut state, mut targets) in &mut query {
if !state.initialized {
state
.translate_x
.init(input.translate_x.unwrap_or(Length::Px(0.0)));
state
.translate_y
.init(input.translate_y.unwrap_or(Length::Px(0.0)));
state.scale.init(input.scale.unwrap_or(1.0));
state.scale_x.init(input.scale_x.unwrap_or(1.0));
state.scale_y.init(input.scale_y.unwrap_or(1.0));
state.rotate.init(input.rotate.unwrap_or(0.0));
state.opacity.init(input.opacity.unwrap_or(1.0));
if let Some(c) = input.background_color {
state.color.init(c);
}
state.width.init(input.width.unwrap_or(Length::Auto));
state.height.init(input.height.unwrap_or(Length::Auto));
state
.max_width
.init(input.max_width.unwrap_or(Length::Auto));
state
.max_height
.init(input.max_height.unwrap_or(Length::Auto));
state.filter.wire = targets
.filter_input
.map(|f| f.0.clone())
.unwrap_or_default();
state.filter.current = targets
.resolved_filter
.as_deref()
.map(|c| c.passes.clone())
.unwrap_or_default();
state.backdrop_filter.wire = targets
.backdrop_input
.map(|f| f.0.clone())
.unwrap_or_default();
state.backdrop_filter.current = targets
.resolved_backdrop
.as_deref()
.map(|c| c.0.passes.clone())
.unwrap_or_default();
state
.transform3d
.init(&input.transform3d.clone().unwrap_or_default());
state.initialized = true;
}
let skip_transform = targets.anim.is_some_and(|a| a.0.has_transform());
let skip_opacity = targets
.anim
.is_some_and(|a| a.0.contains(AnimatableProperty::Opacity));
let skip_bg = targets
.anim
.is_some_and(|a| a.0.contains(AnimatableProperty::BackgroundColor));
let skip_filter = targets.anim.is_some_and(|a| a.0.has_filter_params());
let skip_backdrop = targets.anim.is_some_and(|a| a.0.has_backdrop_params());
let skip_transform3d = targets.anim.is_some_and(|a| a.0.has_transform3d());
if input.spec.for_transform().is_some() && !skip_transform {
let s = input.spec.for_transform();
let tx = input
.translate_x
.map(|t| length_to_val(state.translate_x.drive(t, s, dt)));
let ty = input
.translate_y
.map(|t| length_to_val(state.translate_y.drive(t, s, dt)));
let sc = input.scale.map(|t| state.scale.drive(t, s, dt));
let scx = input.scale_x.map(|t| state.scale_x.drive(t, s, dt));
let scy = input.scale_y.map(|t| state.scale_y.drive(t, s, dt));
let rot = input.rotate.map(|t| state.rotate.drive(t, s, dt));
let new = build_ui_transform(tx, ty, sc, scx, scy, rot);
if *targets.transform != new {
let translate_only = targets.transform.scale == new.scale
&& targets.transform.rotation == new.rotation;
if targets.promoted.is_some() && translate_only {
dirt.composite_only.push(entity);
} else {
dirt.nodes.push(entity);
}
*targets.transform = new;
}
}
if input.spec.for_transform3d().is_some()
&& !skip_transform3d
&& let Some(target) = &input.transform3d
&& let Some(t3d) = &mut targets.transform3d
{
let new = state
.transform3d
.drive(target, input.spec.for_transform3d(), dt);
if t3d.0 != new {
t3d.0 = new;
}
}
let alpha = if !skip_opacity && let Some(target) = input.opacity {
Some(state.opacity.drive(target, input.spec.for_opacity(), dt))
} else {
None
};
let promoted = targets.promoted.is_some();
if !skip_bg && let Some(target) = input.background_color {
let mut rgba = state.color.drive(target, input.spec.for_background(), dt);
if let Some(a) = alpha
&& !promoted
{
rgba[3] = a;
}
let color = rgba_to_color(rgba);
match &mut targets.bg {
Some(c) if c.0 != color => {
c.0 = color;
dirt.nodes.push(entity);
}
Some(_) => {}
None => {
commands.entity(entity).insert(BackgroundColor(color));
dirt.nodes.push(entity);
}
}
}
if let Some(alpha) = alpha
&& promoted
{
if let Some(la) = &mut targets.layer_alpha
&& la.0 != alpha
{
la.0 = alpha;
dirt.composite_only.push(entity);
}
} else if let Some(alpha) = alpha {
let mut wrote = false;
if let Some(c) = &mut targets.bg
&& c.0.alpha() != alpha
{
c.0 = c.0.with_alpha(alpha);
wrote = true;
}
if let Some(tc) = &mut targets.text
&& tc.0.alpha() != alpha
{
tc.0 = tc.0.with_alpha(alpha);
wrote = true;
}
if let Some(img) = &mut targets.image
&& img.color.alpha() != alpha
{
img.color = img.color.with_alpha(alpha);
wrote = true;
}
if wrote {
dirt.nodes.push(entity);
}
}
if input.spec.for_size().is_some()
&& let Some(node) = targets.node.as_mut()
{
let s = input.spec.for_size();
if let Some(t) = input.width {
let v = length_to_val(state.width.drive(t, s, dt));
if node.width != v {
node.width = v;
}
}
if let Some(t) = input.height {
let v = length_to_val(state.height.drive(t, s, dt));
if node.height != v {
node.height = v;
}
}
if let Some(t) = input.max_width {
let v = length_to_val(state.max_width.drive(t, s, dt));
if node.max_width != v {
node.max_width = v;
}
}
if let Some(t) = input.max_height {
let v = length_to_val(state.max_height.drive(t, s, dt));
if node.max_height != v {
node.max_height = v;
}
}
}
if !skip_filter
&& state.filter.drive(
targets.filter_input.map(|f| &f.0),
targets.resolved_filter.as_mut().map(Mut::reborrow),
input.spec.for_filter(),
filter_registry.as_deref(),
assets.as_deref(),
dt,
)
{
dirt.composite_only.push(entity);
}
if !skip_backdrop
&& state.backdrop_filter.drive(
targets.backdrop_input.map(|f| &f.0),
targets
.resolved_backdrop
.as_mut()
.map(|m| m.reborrow().map_unchanged(|b| &mut b.0)),
input.spec.for_backdrop_filter(),
filter_registry.as_deref(),
assets.as_deref(),
dt,
)
{
dirt.composite_only.push(entity);
}
}
}
fn color_to_rgba(color: Color) -> [f32; 4] {
let s = color.to_srgba();
[s.red, s.green, s.blue, s.alpha]
}
fn rgba_to_color(rgba: [f32; 4]) -> Color {
Color::srgba(rgba[0], rgba[1], rgba[2], rgba[3])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::animations::AnimatedBindings;
use std::time::Duration;
fn timing(duration: f32, easing: Easing) -> ChannelTransition {
ChannelTransition {
duration: Some(WireTime::from_secs(duration)),
easing,
delay: WireTime::from_secs(0.0),
stiffness: None,
damping: None,
mass: 1.0,
}
}
fn parse<T: serde::de::DeserializeOwned>(json: serde_json::Value) -> T {
serde_json::from_value(json).expect("valid json")
}
#[test]
fn channel_resolution_falls_back_to_all() {
let t: Transition = parse(serde_json::json!({
"all": { "duration": 100 },
"opacity": { "duration": 200 },
}));
let secs = |c: &ChannelTransition| c.duration.map(WireTime::seconds);
assert!(t.for_opacity().is_some());
assert_eq!(secs(t.for_opacity().unwrap()), Some(0.2));
assert_eq!(secs(t.for_transform().unwrap()), Some(0.1));
assert_eq!(secs(t.for_background().unwrap()), Some(0.1));
let t: Transition = parse(serde_json::json!({ "opacity": { "duration": 50 } }));
assert!(t.for_transform().is_none());
assert!(t.for_opacity().is_some());
}
#[test]
fn filter_channel_falls_back_to_all() {
let secs = |c: &ChannelTransition| c.duration.map(WireTime::seconds);
let t: Transition = parse(serde_json::json!({
"all": { "duration": 100 },
"filter": { "duration": 400 },
}));
assert_eq!(secs(t.for_filter().unwrap()), Some(0.4));
let t: Transition = parse(serde_json::json!({ "all": { "duration": 100 } }));
assert_eq!(secs(t.for_filter().unwrap()), Some(0.1));
let t: Transition = parse(serde_json::json!({ "opacity": { "duration": 50 } }));
assert!(t.for_filter().is_none());
}
#[test]
fn to_driver_selects_spring_or_timing() {
let spring = ChannelTransition {
duration: None,
easing: Easing::Linear,
delay: WireTime::from_secs(0.0),
stiffness: Some(120.0),
damping: Some(14.0),
mass: 1.0,
};
assert!(matches!(spring.to_driver(1.0), Driver::Spring { .. }));
assert!(matches!(
timing(0.3, Easing::Linear).to_driver(1.0),
Driver::Timing { .. }
));
let delayed = ChannelTransition {
delay: WireTime::from_secs(0.2),
..timing(0.3, Easing::Linear)
};
assert!(matches!(delayed.to_driver(1.0), Driver::Delay { .. }));
}
#[test]
fn channel_snaps_without_spec_and_eases_with_one() {
let mut ch = Channel::default();
ch.init(1.0);
assert_eq!(ch.drive(0.5, None, 0.016), 0.5);
let mut ch = Channel::default();
ch.init(1.0);
let spec = timing(1.0, Easing::Linear);
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}");
let v = ch.drive(0.0, Some(&spec), 0.5);
assert!((v - 0.0).abs() < 1e-3, "end expected 0, got {v}");
assert!(ch.runner.is_none(), "runner dropped once finished");
}
#[test]
fn color_channel_lerps_to_target() {
let mut c = ProgressChannel::<[f32; 4]>::default();
c.init([0.0, 0.0, 0.0, 1.0]);
let spec = timing(1.0, Easing::Linear);
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);
assert!((mid[0] - 0.5).abs() < 1e-3);
assert!((mid[1] - 0.25).abs() < 1e-3);
assert!((mid[2] - 0.0).abs() < 1e-3);
}
fn drive_world() -> (World, Schedule) {
let mut world = World::new();
world.init_resource::<crate::layer::LayerContentDirt>();
world.insert_resource(Time::<()>::default());
let mut schedule = Schedule::default();
schedule.add_systems(drive_transitions);
(world, schedule)
}
fn advance(world: &mut World, secs: f32) {
world
.resource_mut::<Time>()
.advance_by(Duration::from_secs_f32(secs));
}
#[test]
fn system_eases_scale_on_press_then_release() {
let (mut world, mut schedule) = drive_world();
let spec = Transition {
transform: Some(timing(1.0, Easing::Linear)),
..Default::default()
};
let e = world
.spawn((
TransitionInput {
spec: spec.clone(),
scale: Some(1.0),
..Default::default()
},
TransitionState::default(),
UiTransform::default(),
))
.id();
schedule.run(&mut world);
assert_eq!(world.entity(e).get::<UiTransform>().unwrap().scale.x, 1.0);
world
.entity_mut(e)
.get_mut::<TransitionInput>()
.unwrap()
.scale = Some(0.95);
advance(&mut world, 0.5);
schedule.run(&mut world);
let sx = world.entity(e).get::<UiTransform>().unwrap().scale.x;
assert!(
(sx - 0.975).abs() < 1e-2,
"mid-press expected ~0.975, got {sx}"
);
advance(&mut world, 0.5);
schedule.run(&mut world);
let sx = world.entity(e).get::<UiTransform>().unwrap().scale.x;
assert!((sx - 0.95).abs() < 1e-3, "pressed expected 0.95, got {sx}");
world
.entity_mut(e)
.get_mut::<TransitionInput>()
.unwrap()
.scale = Some(1.0);
advance(&mut world, 0.5);
schedule.run(&mut world);
let sx = world.entity(e).get::<UiTransform>().unwrap().scale.x;
assert!(
(sx - 0.975).abs() < 1e-2,
"mid-release expected ~0.975, got {sx}"
);
}
#[test]
fn system_eases_transform3d() {
use crate::layer::transform3d::LayerTransform3d;
use crate::protocol::Transform3d;
let (mut world, mut schedule) = drive_world();
let spec = Transition {
transform3d: Some(timing(1.0, Easing::Linear)),
..Default::default()
};
let base = Transform3d::default();
let e = world
.spawn((
TransitionInput {
spec: spec.clone(),
transform3d: Some(base.clone()),
..Default::default()
},
TransitionState::default(),
UiTransform::default(),
LayerTransform3d(base.clone()),
))
.id();
schedule.run(&mut world);
let t = world.entity(e).get::<LayerTransform3d>().unwrap().0.clone();
assert!(t.is_identity());
let target = Transform3d {
rotate_y: Some(crate::protocol::Animatable::Static(
crate::protocol::Angle::from_radians(std::f32::consts::FRAC_PI_2),
)),
perspective: Some(crate::protocol::Animatable::Static(800.0)),
..Default::default()
};
world
.entity_mut(e)
.get_mut::<TransitionInput>()
.unwrap()
.transform3d = Some(target.clone());
advance(&mut world, 0.5);
schedule.run(&mut world);
let t = world.entity(e).get::<LayerTransform3d>().unwrap().0.clone();
let ry = t.rotate_y.static_val().unwrap().radians();
assert!(
(ry - std::f32::consts::FRAC_PI_4).abs() < 0.05,
"mid-ease expected ~45°, got {}°",
ry.to_degrees()
);
assert_eq!(
t.perspective.static_val(),
Some(800.0),
"ortho→perspective snaps"
);
advance(&mut world, 0.6);
schedule.run(&mut world);
let t = world.entity(e).get::<LayerTransform3d>().unwrap().0.clone();
assert!(
(t.rotate_y.static_val().unwrap().radians() - std::f32::consts::FRAC_PI_2).abs() < 1e-3
);
let e2 = world
.spawn((
TransitionInput {
spec: Transition {
opacity: Some(timing(1.0, Easing::Linear)),
..Default::default()
},
transform3d: Some(target.clone()),
..Default::default()
},
TransitionState::default(),
UiTransform::default(),
LayerTransform3d(base),
))
.id();
schedule.run(&mut world);
let t2 = world
.entity(e2)
.get::<LayerTransform3d>()
.unwrap()
.0
.clone();
assert!(t2.is_identity());
let e3 = world
.spawn((
TransitionInput {
spec,
transform3d: Some(target.clone()),
..Default::default()
},
TransitionState::default(),
UiTransform::default(),
))
.id();
advance(&mut world, 0.1);
schedule.run(&mut world);
assert!(world.entity(e3).get::<LayerTransform3d>().is_none());
}
#[test]
fn system_eases_percent_translate() {
let (mut world, mut schedule) = drive_world();
let spec = Transition {
transform: Some(timing(1.0, Easing::Linear)),
..Default::default()
};
let e = world
.spawn((
TransitionInput {
spec,
translate_x: Some(Length::Percent(0.0)),
..Default::default()
},
TransitionState::default(),
UiTransform::default(),
))
.id();
schedule.run(&mut world);
assert_eq!(
world.entity(e).get::<UiTransform>().unwrap().translation.x,
Val::Percent(0.0)
);
world
.entity_mut(e)
.get_mut::<TransitionInput>()
.unwrap()
.translate_x = Some(Length::Percent(100.0));
advance(&mut world, 0.5);
schedule.run(&mut world);
let tx = world.entity(e).get::<UiTransform>().unwrap().translation.x;
assert!(
matches!(tx, Val::Percent(v) if (v - 50.0).abs() < 1.0),
"mid expected ~50%, got {tx:?}"
);
advance(&mut world, 0.5);
schedule.run(&mut world);
assert_eq!(
world.entity(e).get::<UiTransform>().unwrap().translation.x,
Val::Percent(100.0)
);
}
#[test]
fn animated_style_channel_wins_over_transition() {
let (mut world, mut schedule) = drive_world();
let spec = Transition {
transform: Some(timing(1.0, Easing::Linear)),
..Default::default()
};
let bindings = AnimatedBindings(
[(
crate::animations::AnimatableProperty::Scale,
crate::animations::protocol::Binding::Shared { id: 1 },
)]
.into(),
);
let e = world
.spawn((
TransitionInput {
spec,
scale: Some(1.0),
..Default::default()
},
TransitionState::default(),
UiTransform::from_scale(Vec2::splat(2.0)), AnimatedNode(bindings),
))
.id();
schedule.run(&mut world);
world
.entity_mut(e)
.get_mut::<TransitionInput>()
.unwrap()
.scale = Some(0.95);
advance(&mut world, 0.5);
schedule.run(&mut world);
assert_eq!(world.entity(e).get::<UiTransform>().unwrap().scale.x, 2.0);
}
#[test]
fn filter_param_binding_gates_filter_transition() {
use crate::animations::ValueKind;
use std::sync::Arc;
let (mut world, mut schedule) = drive_world();
let spec = Transition {
filter: Some(timing(1.0, Easing::Linear)),
..Default::default()
};
let pass = |amount: f32| crate::filters::ResolvedFilterPass {
shader: Handle::default(),
params: vec![Vec4::new(amount, 0.0, 0.0, 0.0)],
layout: Arc::from(vec![crate::filters::ParamSlot {
name: "amount",
kind: ValueKind::Scalar,
vec: 0,
comp: 0,
len: 1,
}]),
wire_index: 0,
};
let wire = |amount: f32| -> crate::filters::FilterChain {
serde_json::from_value(serde_json::json!(
{ "name": "grayscale", "params": { "amount": amount } }
))
.unwrap()
};
let chain = |amount: f32| crate::filters::ResolvedFilterChain {
passes: vec![pass(amount)],
outset_px: 0,
always_dirty: false,
version: 1,
scale: 1.0,
};
let bindings = AnimatedBindings(
[(
crate::animations::AnimatableProperty::FilterParam {
index: 0,
name: "amount".into(),
},
crate::animations::protocol::Binding::Shared { id: 1 },
)]
.into(),
);
let spawn = |world: &mut World, gated: bool| {
let mut e = world.spawn((
TransitionInput {
spec: spec.clone(),
..Default::default()
},
TransitionState::default(),
UiTransform::default(),
crate::filters::FilterInput(wire(0.0)),
chain(0.0),
));
if gated {
e.insert(AnimatedNode(bindings.clone()));
}
e.id()
};
let gated = spawn(&mut world, true);
let control = spawn(&mut world, false);
schedule.run(&mut world);
for e in [gated, control] {
*world
.entity_mut(e)
.get_mut::<crate::filters::FilterInput>()
.unwrap() = crate::filters::FilterInput(wire(1.0));
let mut em = world.entity_mut(e);
let mut c = em.get_mut::<crate::filters::ResolvedFilterChain>().unwrap();
c.passes = vec![pass(1.0)];
c.version = 2;
}
advance(&mut world, 0.1);
schedule.run(&mut world);
let c = world
.entity(control)
.get::<crate::filters::ResolvedFilterChain>()
.unwrap();
let w = c.passes[0].params[0].x;
assert!(
w > 0.0 && w < 1.0,
"control: transition eased over the snap, got {w}"
);
assert_eq!(c.version, 3, "control: transition bumped the version");
let c = world
.entity(gated)
.get::<crate::filters::ResolvedFilterChain>()
.unwrap();
assert_eq!(
c.passes[0].params[0].x, 1.0,
"gated: the transition must not touch the chain"
);
assert_eq!(c.version, 2, "gated: version stays the resolver's");
}
#[test]
fn settled_transition_does_not_dirty_components() {
#[derive(Resource, Default)]
struct Dirty(usize);
let (mut world, mut schedule) = drive_world();
world.init_resource::<Dirty>();
let spec = Transition {
transform: Some(timing(0.2, Easing::Linear)),
background_color: Some(timing(0.2, Easing::Linear)),
opacity: Some(timing(0.2, Easing::Linear)),
..Default::default()
};
let e = world
.spawn((
TransitionInput {
spec,
scale: Some(1.0),
opacity: Some(0.5),
background_color: Some([1.0, 0.0, 0.0, 1.0]),
..Default::default()
},
TransitionState::default(),
UiTransform::default(),
BackgroundColor(Color::WHITE),
))
.id();
type AnyTargetChanged = Or<(Changed<UiTransform>, Changed<BackgroundColor>)>;
let mut detect = Schedule::default();
detect.add_systems(|q: Query<(), AnyTargetChanged>, mut dirty: ResMut<Dirty>| {
dirty.0 = q.iter().count();
});
schedule.run(&mut world);
world
.entity_mut(e)
.get_mut::<TransitionInput>()
.unwrap()
.scale = Some(0.9);
advance(&mut world, 0.5);
schedule.run(&mut world);
detect.run(&mut world);
advance(&mut world, 0.5);
schedule.run(&mut world);
detect.run(&mut world);
assert_eq!(
world.resource::<Dirty>().0,
0,
"a settled transition must not dirty anything"
);
}
#[test]
fn lerp_length_same_unit_else_snaps() {
assert_eq!(Length::Px(0.0).lerp(Length::Px(10.0), 0.5), Length::Px(5.0));
assert_eq!(
Length::Percent(0.0).lerp(Length::Percent(100.0), 0.25),
Length::Percent(25.0)
);
assert_eq!(Length::Auto.lerp(Length::Px(10.0), 0.5), Length::Px(10.0));
assert_eq!(
Length::Px(0.0).lerp(Length::Percent(10.0), 0.5),
Length::Percent(10.0)
);
}
fn px(l: Length) -> f32 {
match l {
Length::Px(v) => v,
other => panic!("expected Px, got {other:?}"),
}
}
#[test]
fn length_channel_eases_then_idles() {
let mut ch = ProgressChannel::<Length>::default();
ch.init(Length::Px(0.0));
let spec = timing(1.0, Easing::Linear);
assert!((px(ch.drive(Length::Px(100.0), Some(&spec), 0.0)) - 0.0).abs() < 1e-3);
assert!((px(ch.drive(Length::Px(100.0), Some(&spec), 0.5)) - 50.0).abs() < 1e-3);
assert!((px(ch.drive(Length::Px(100.0), Some(&spec), 0.5)) - 100.0).abs() < 1e-3);
assert!(ch.runner.is_none(), "runner dropped once settled");
assert_eq!(
ch.drive(Length::Px(100.0), Some(&spec), 0.5),
Length::Px(100.0)
);
}
#[test]
fn system_eases_max_height_layout() {
let (mut world, mut schedule) = drive_world();
let spec = Transition {
size: Some(timing(1.0, Easing::Linear)),
..Default::default()
};
let e = world
.spawn((
TransitionInput {
spec,
max_height: Some(Length::Px(120.0)),
..Default::default()
},
TransitionState::default(),
Node::default(),
UiTransform::default(),
))
.id();
schedule.run(&mut world);
world
.entity_mut(e)
.get_mut::<TransitionInput>()
.unwrap()
.max_height = Some(Length::Px(0.0));
advance(&mut world, 0.5);
schedule.run(&mut world);
let mh = world.entity(e).get::<Node>().unwrap().max_height;
assert!(
matches!(mh, Val::Px(v) if (v - 60.0).abs() < 1.0),
"mid expected ~60px, got {mh:?}"
);
advance(&mut world, 0.5);
schedule.run(&mut world);
let mh = world.entity(e).get::<Node>().unwrap().max_height;
assert!(
matches!(mh, Val::Px(v) if v.abs() < 1e-3),
"settled expected 0px, got {mh:?}"
);
}
#[test]
fn system_eases_scroll_toward_target() {
let mut world = World::new();
world.init_resource::<crate::layer::LayerContentDirt>();
world.insert_resource(Time::<()>::default());
let mut schedule = Schedule::default();
schedule.add_systems(drive_scroll_transition);
let e = world
.spawn((
ScrollTransitionInput(timing(1.0, Easing::Linear)),
ScrollTransitionState::default(),
ScrollPosition::default(),
))
.id();
schedule.run(&mut world);
assert_eq!(
world.entity(e).get::<ScrollPosition>().unwrap().0,
Vec2::ZERO
);
world
.entity_mut(e)
.get_mut::<ScrollTransitionState>()
.unwrap()
.target = Vec2::new(0.0, 100.0);
advance(&mut world, 0.5);
schedule.run(&mut world);
let y = world.entity(e).get::<ScrollPosition>().unwrap().0.y;
assert!((y - 50.0).abs() < 1.0, "mid-ease expected ~50, got {y}");
advance(&mut world, 0.5);
schedule.run(&mut world);
assert_eq!(
world.entity(e).get::<ScrollPosition>().unwrap().0,
Vec2::new(0.0, 100.0)
);
}
#[test]
fn scroll_direct_write_snaps_the_ease() {
let mut world = World::new();
world.init_resource::<crate::layer::LayerContentDirt>();
world.insert_resource(Time::<()>::default());
let mut schedule = Schedule::default();
schedule.add_systems(drive_scroll_transition);
let e = world
.spawn((
ScrollTransitionInput(timing(1.0, Easing::Linear)),
ScrollTransitionState::default(),
ScrollPosition::default(),
))
.id();
schedule.run(&mut world);
world
.entity_mut(e)
.get_mut::<ScrollTransitionState>()
.unwrap()
.target = Vec2::new(0.0, 100.0);
advance(&mut world, 0.5);
schedule.run(&mut world);
let y = world.entity(e).get::<ScrollPosition>().unwrap().0.y;
assert!(y > 0.0 && y < 100.0, "mid-ease expected, got {y}");
world.entity_mut(e).get_mut::<ScrollPosition>().unwrap().0 = Vec2::new(0.0, 42.0);
advance(&mut world, 0.25);
schedule.run(&mut world);
assert_eq!(
world.entity(e).get::<ScrollPosition>().unwrap().0,
Vec2::new(0.0, 42.0)
);
advance(&mut world, 0.25);
schedule.run(&mut world);
assert_eq!(
world.entity(e).get::<ScrollPosition>().unwrap().0,
Vec2::new(0.0, 42.0)
);
}
#[test]
fn scroll_snap_to_parks_a_mid_flight_ease() {
let mut world = World::new();
world.init_resource::<crate::layer::LayerContentDirt>();
world.insert_resource(Time::<()>::default());
let mut schedule = Schedule::default();
schedule.add_systems(drive_scroll_transition);
let e = world
.spawn((
ScrollTransitionInput(timing(1.0, Easing::Linear)),
ScrollTransitionState::default(),
ScrollPosition::default(),
))
.id();
schedule.run(&mut world);
world
.entity_mut(e)
.get_mut::<ScrollTransitionState>()
.unwrap()
.target = Vec2::new(0.0, 100.0);
advance(&mut world, 0.5);
schedule.run(&mut world);
let live = world.entity(e).get::<ScrollPosition>().unwrap().0;
assert!(
live.y > 0.0 && live.y < 100.0,
"mid-ease expected, got {live:?}"
);
world
.entity_mut(e)
.get_mut::<ScrollTransitionState>()
.unwrap()
.snap_to(live);
advance(&mut world, 0.5);
schedule.run(&mut world);
assert_eq!(world.entity(e).get::<ScrollPosition>().unwrap().0, live);
advance(&mut world, 0.5);
schedule.run(&mut world);
assert_eq!(world.entity(e).get::<ScrollPosition>().unwrap().0, live);
}
}