use bevy::prelude::*;
use crate::provider::{self, PropertyTarget, ValueSource};
use crate::resources::PfValue;
use bevy_pf_xaml::value as v;
#[derive(Debug, Clone, Default)]
pub struct PfStoryboard {
pub children: Vec<PfAnimationSpec>,
}
#[derive(Debug, Clone)]
pub struct PfAnimationSpec {
pub target_name: Option<String>,
pub target_property: String,
pub kind: PfAnimKind,
pub duration: f32,
pub begin_time: f32,
pub repeat: PfRepeat,
pub auto_reverse: bool,
pub fill: PfFill,
pub easing: PfEasing,
pub speed_ratio: f32,
}
#[derive(Debug, Clone)]
pub enum PfAnimKind {
Double {
from: Option<f64>,
to: Option<f64>,
by: Option<f64>,
},
Color {
from: Option<v::PfColor>,
to: v::PfColor,
},
DoubleKeyFrames { frames: Vec<PfKeyFrame> },
ColorKeyFrames { frames: Vec<PfKeyFrame> },
}
#[derive(Debug, Clone)]
pub struct PfKeyFrame {
pub time: Option<f32>,
pub value: PfKeyValue,
pub interp: PfKeyInterp,
}
#[derive(Debug, Clone, Copy)]
pub enum PfKeyValue {
Double(f64),
Color(v::PfColor),
}
#[derive(Debug, Clone, Copy)]
pub enum PfKeyInterp {
Discrete,
Linear,
Easing(PfEasing),
Spline {
x1: f32,
y1: f32,
x2: f32,
y2: f32,
},
}
impl PfKeyInterp {
pub fn ease(self, p: f32) -> f32 {
match self {
PfKeyInterp::Discrete => {
if p >= 1.0 {
1.0
} else {
0.0
}
}
PfKeyInterp::Linear => p,
PfKeyInterp::Easing(e) => e.ease(p),
PfKeyInterp::Spline { x1, y1, x2, y2 } => {
let bez = |a: f32, b: f32, s: f32| {
3.0 * (1.0 - s) * (1.0 - s) * s * a + 3.0 * (1.0 - s) * s * s * b + s * s * s
};
let p = p.clamp(0.0, 1.0);
let mut s = p;
for _ in 0..8 {
let x = bez(x1, x2, s) - p;
if x.abs() < 1e-4 {
break;
}
let dx = 3.0 * (1.0 - s) * (1.0 - s) * x1
+ 6.0 * (1.0 - s) * s * (x2 - x1)
+ 3.0 * s * s * (1.0 - x2);
if dx.abs() < 1e-6 {
break;
}
s = (s - x / dx).clamp(0.0, 1.0);
}
bez(y1, y2, s)
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum PfEasing {
#[default]
Linear,
Quadratic(EasingMode),
Cubic(EasingMode),
Quartic(EasingMode),
Quintic(EasingMode),
Sine(EasingMode),
Circle(EasingMode),
Back(EasingMode),
Elastic(EasingMode),
Bounce(EasingMode),
Exponential(EasingMode),
AccelDecel {
accel: f32,
decel: f32,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EasingMode {
In,
#[default]
Out,
InOut,
}
impl PfEasing {
pub fn ease(self, t: f32) -> f32 {
fn with_mode(mode: EasingMode, f: impl Fn(f32) -> f32, t: f32) -> f32 {
match mode {
EasingMode::In => f(t),
EasingMode::Out => 1.0 - f(1.0 - t),
EasingMode::InOut => {
if t < 0.5 {
f(t * 2.0) / 2.0
} else {
1.0 - f((1.0 - t) * 2.0) / 2.0
}
}
}
}
let t = t.clamp(0.0, 1.0);
match self {
PfEasing::Linear => t,
PfEasing::Quadratic(m) => with_mode(m, |x| x * x, t),
PfEasing::Cubic(m) => with_mode(m, |x| x * x * x, t),
PfEasing::Quartic(m) => with_mode(m, |x| x * x * x * x, t),
PfEasing::Quintic(m) => with_mode(m, |x| x * x * x * x * x, t),
PfEasing::Sine(m) => with_mode(m, |x| 1.0 - (x * std::f32::consts::FRAC_PI_2).cos(), t),
PfEasing::Circle(m) => with_mode(m, |x| 1.0 - (1.0 - x * x).max(0.0).sqrt(), t),
PfEasing::Back(m) => with_mode(
m,
|x| x * x * x - x * 1.0 * (std::f32::consts::PI * x).sin(),
t,
),
PfEasing::Elastic(m) => with_mode(
m,
|x| {
if x <= 0.0 {
0.0
} else {
let osc = 3.0_f32;
let spring = 3.0_f32;
((spring * x).exp() - 1.0) / (spring.exp() - 1.0)
* (x * std::f32::consts::TAU * (osc + 0.5)).sin()
}
},
t,
),
PfEasing::Bounce(m) => with_mode(
m,
|x| {
let x = 1.0 - x;
let v = if x < 1.0 / 2.75 {
7.5625 * x * x
} else if x < 2.0 / 2.75 {
let x = x - 1.5 / 2.75;
7.5625 * x * x + 0.75
} else if x < 2.5 / 2.75 {
let x = x - 2.25 / 2.75;
7.5625 * x * x + 0.9375
} else {
let x = x - 2.625 / 2.75;
7.5625 * x * x + 0.984375
};
1.0 - v
},
t,
),
PfEasing::Exponential(m) => with_mode(
m,
|x| {
((2.0_f32 * x).exp() - 1.0) / (2.0_f32.exp() - 1.0)
},
t,
),
PfEasing::AccelDecel { accel, decel } => {
let a = accel.clamp(0.0, 1.0);
let d = decel.clamp(0.0, 1.0 - a);
let denom = 1.0 - a / 2.0 - d / 2.0;
if denom <= 0.0 {
return t;
}
let v = 1.0 / denom;
if t < a {
v * t * t / (2.0 * a)
} else if t <= 1.0 - d {
v * (t - a / 2.0)
} else {
let tail = 1.0 - t;
1.0 - v * tail * tail / (2.0 * d)
}
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PfRepeat {
Once,
Count(f32),
Forever,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PfFill {
HoldEnd,
Stop,
}
#[derive(Debug, Clone)]
pub struct PfVisualTransition {
pub from: Option<String>,
pub to: Option<String>,
pub duration: f32,
pub easing: Option<PfEasing>,
}
#[derive(Debug, Clone)]
pub struct PfVisualStateGroup {
pub name: String,
pub states: Vec<PfVisualState>,
pub transitions: Vec<PfVisualTransition>,
}
#[derive(Debug, Clone)]
pub struct PfVisualState {
pub name: String,
pub storyboard: Option<std::sync::Arc<PfStoryboard>>,
}
#[derive(Component, Debug, Clone)]
pub struct PfVisualStates {
pub groups: Vec<PfVisualStateGroup>,
pub current: Vec<Option<String>>,
pub touched: Vec<Vec<(Entity, PfAnimTarget, Option<PfValue>)>>,
}
#[derive(Debug, Clone)]
pub struct PfEventTrigger {
pub event: String,
pub storyboard: std::sync::Arc<PfStoryboard>,
}
#[derive(Component, Debug, Default)]
pub struct PfPendingStoryboards(pub Vec<std::sync::Arc<PfStoryboard>>);
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PfAnimTarget {
Store(PropertyTarget),
Transform(TransformField),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransformField {
ScaleX,
ScaleY,
Rotation,
TranslateX,
TranslateY,
}
pub fn transform_field_for(name: &str) -> Option<TransformField> {
Some(match name {
"ScaleX" => TransformField::ScaleX,
"ScaleY" => TransformField::ScaleY,
"Angle" | "Rotation" => TransformField::Rotation,
"X" | "TranslateX" => TransformField::TranslateX,
"Y" | "TranslateY" => TransformField::TranslateY,
_ => return None,
})
}
fn read_transform_field(world: &World, entity: Entity, field: TransformField) -> f64 {
let Some(tf) = world.get::<bevy::ui::UiTransform>(entity) else {
return match field {
TransformField::ScaleX | TransformField::ScaleY => 1.0,
_ => 0.0,
};
};
f64::from(match field {
TransformField::ScaleX => tf.scale.x,
TransformField::ScaleY => tf.scale.y,
TransformField::Rotation => tf.rotation.as_degrees(),
TransformField::TranslateX => match tf.translation.x {
Val::Px(px) => px,
_ => 0.0,
},
TransformField::TranslateY => match tf.translation.y {
Val::Px(px) => px,
_ => 0.0,
},
})
}
fn write_transform_field(world: &mut World, entity: Entity, field: TransformField, value: f64) {
let Ok(mut e) = world.get_entity_mut(entity) else {
return;
};
if e.get::<bevy::ui::UiTransform>().is_none() {
e.insert(bevy::ui::UiTransform::default());
}
let Some(mut tf) = e.get_mut::<bevy::ui::UiTransform>() else {
return;
};
let v = value as f32;
match field {
TransformField::ScaleX => tf.scale.x = v,
TransformField::ScaleY => tf.scale.y = v,
TransformField::Rotation => tf.rotation = bevy::math::Rot2::degrees(v),
TransformField::TranslateX => {
let y = tf.translation.y;
tf.translation = bevy::ui::Val2::new(Val::Px(v), y);
}
TransformField::TranslateY => {
let x = tf.translation.x;
tf.translation = bevy::ui::Val2::new(x, Val::Px(v));
}
}
}
#[derive(Debug)]
struct Running {
target: Entity,
prop: PfAnimTarget,
track: Track,
revert: Option<PfValue>,
begin: f32,
duration: f32,
repeat: PfRepeat,
auto_reverse: bool,
fill: PfFill,
easing: PfEasing,
tag: Option<(Entity, usize)>,
}
#[derive(Debug)]
enum Track {
Simple {
from: PfValue,
to: PfValue,
},
KeyFrames {
base: PfValue,
frames: Vec<(f32, PfValue, PfKeyInterp)>,
},
}
impl Track {
fn sample(&self, progress: f32, duration: f32, easing: PfEasing) -> Option<PfValue> {
match self {
Track::Simple { from, to } => lerp_value(from, to, easing.ease(progress)),
Track::KeyFrames { base, frames } => {
let t = progress * duration;
let mut prev_time = 0.0_f32;
let mut prev_value = base;
for (time, value, interp) in frames {
if t <= *time {
let span = (*time - prev_time).max(1e-6);
let local = ((t - prev_time) / span).clamp(0.0, 1.0);
return lerp_value(prev_value, value, interp.ease(local));
}
prev_time = *time;
prev_value = value;
}
Some(prev_value.clone())
}
}
}
}
fn lerp_value(a: &PfValue, b: &PfValue, t: f32) -> Option<PfValue> {
match (a, b) {
(PfValue::Double(x), PfValue::Double(y)) => {
Some(PfValue::Double(x + (y - x) * f64::from(t)))
}
(PfValue::Color(x), PfValue::Color(y)) => Some(PfValue::Color(lerp_color(*x, *y, t))),
_ => None,
}
}
#[derive(Resource, Default)]
pub struct PfRunningAnimations(Vec<Running>);
pub fn begin_storyboard(
world: &mut World,
host: Entity,
scope_root: Entity,
storyboard: &PfStoryboard,
) {
begin_storyboard_tagged(world, host, scope_root, storyboard, None);
}
pub(crate) fn begin_storyboard_tagged(
world: &mut World,
host: Entity,
scope_root: Entity,
storyboard: &PfStoryboard,
tag: Option<(Entity, usize)>,
) -> Vec<(Entity, PfAnimTarget, Option<PfValue>)> {
begin_storyboard_timed(world, host, scope_root, storyboard, tag, None)
}
pub(crate) fn begin_storyboard_timed(
world: &mut World,
host: Entity,
scope_root: Entity,
storyboard: &PfStoryboard,
tag: Option<(Entity, usize)>,
timing: Option<(f32, Option<PfEasing>)>,
) -> Vec<(Entity, PfAnimTarget, Option<PfValue>)> {
let mut started = Vec::new();
let now = world
.get_resource::<Time>()
.map(|t| t.elapsed_secs())
.unwrap_or(0.0);
let names_root = {
let mut e = scope_root;
loop {
if world.get::<crate::components::XamlNames>(e).is_some() {
break Some(e);
}
match world.get::<ChildOf>(e) {
Some(p) => e = p.parent(),
None => break None,
}
}
};
for spec in &storyboard.children {
let target = match &spec.target_name {
None => host,
Some(name) => {
let found = world
.get::<crate::components::PfTemplateParts>(scope_root)
.and_then(|p| p.get(name))
.or_else(|| {
names_root
.and_then(|r| world.get::<crate::components::XamlNames>(r))
.and_then(|n| n.get(name))
});
match found {
Some(e) => e,
None => {
warn!("bevy_pf: storyboard TargetName `{name}` not found; skipped");
continue;
}
}
}
};
let prop = match provider::property_target_for(&spec.target_property) {
Some(p) => PfAnimTarget::Store(p),
None => match transform_field_for(&spec.target_property) {
Some(f) => PfAnimTarget::Transform(f),
None => {
warn!(
"bevy_pf: storyboard TargetProperty `{}` is not animatable yet; skipped",
spec.target_property
);
continue;
}
},
};
let base = match prop {
PfAnimTarget::Store(p) => world
.get::<provider::PfPropertyStore>(target)
.and_then(|s| s.effective_below(p, ValueSource::Animation))
.and_then(|(_, v)| v.clone()),
PfAnimTarget::Transform(f) => {
Some(PfValue::Double(read_transform_field(world, target, f)))
}
};
let revert = match prop {
PfAnimTarget::Transform(_) => base.clone(),
PfAnimTarget::Store(_) => None,
};
let resolve_frames = |frames: &[PfKeyFrame], duration: f32, color: bool| {
let n = frames.len().max(1) as f32;
let mut out: Vec<(f32, PfValue, PfKeyInterp)> = frames
.iter()
.enumerate()
.map(|(i, f)| {
let time = f.time.unwrap_or((i as f32 + 1.0) / n * duration);
let value = match (f.value, color) {
(PfKeyValue::Double(d), _) if !color => PfValue::Double(d),
(PfKeyValue::Color(c), _) if color => PfValue::Color(c),
(PfKeyValue::Double(d), _) => PfValue::Double(d),
(PfKeyValue::Color(c), _) => PfValue::Color(c),
};
(time, value, f.interp)
})
.collect();
out.sort_by(|a, b| a.0.total_cmp(&b.0));
out
};
let duration = (timing.map(|(d, _)| d).unwrap_or(spec.duration)
/ spec.speed_ratio.max(f32::EPSILON))
.max(1.0 / 240.0);
let track = match &spec.kind {
PfAnimKind::Double { from, to, by } => {
let from = from
.or_else(|| base.as_ref().and_then(pf_as_f64))
.unwrap_or(0.0);
let to = to.unwrap_or_else(|| from + by.unwrap_or(0.0));
Track::Simple {
from: PfValue::Double(from),
to: PfValue::Double(to),
}
}
PfAnimKind::Color { from, to } => {
let from = from
.or_else(|| base.as_ref().and_then(pf_as_color))
.unwrap_or(v::PfColor::TRANSPARENT);
Track::Simple {
from: PfValue::Color(from),
to: PfValue::Color(*to),
}
}
PfAnimKind::DoubleKeyFrames { frames } => Track::KeyFrames {
base: PfValue::Double(base.as_ref().and_then(pf_as_f64).unwrap_or(0.0)),
frames: resolve_frames(frames, duration, false),
},
PfAnimKind::ColorKeyFrames { frames } => Track::KeyFrames {
base: PfValue::Color(
base.as_ref()
.and_then(pf_as_color)
.unwrap_or(v::PfColor::TRANSPARENT),
),
frames: resolve_frames(frames, duration, true),
},
};
let touch_revert = revert.clone();
world
.get_resource_or_insert_with(PfRunningAnimations::default)
.0
.push(Running {
tag,
target,
prop,
track,
revert,
begin: now + spec.begin_time,
duration,
repeat: spec.repeat,
auto_reverse: spec.auto_reverse,
fill: spec.fill,
easing: match timing {
Some((_, Some(e))) => e,
_ => spec.easing,
},
});
if !started.iter().any(|(t, p, _)| *t == target && *p == prop) {
started.push((target, prop, touch_revert));
}
}
started
}
fn pf_as_f64(v: &PfValue) -> Option<f64> {
match v {
PfValue::Double(d) => Some(*d),
PfValue::String(s) => s.trim().parse().ok(),
_ => None,
}
}
fn pf_as_color(value: &PfValue) -> Option<v::PfColor> {
match value {
PfValue::Color(c) => Some(*c),
PfValue::Brush(v::PfBrush::Solid(c)) => Some(*c),
PfValue::String(s) => s.parse().ok(),
_ => None,
}
}
fn lerp_color(a: v::PfColor, b: v::PfColor, t: f32) -> v::PfColor {
let l = |x: u8, y: u8| -> u8 { (x as f32 + (y as f32 - x as f32) * t).round() as u8 };
v::PfColor::rgba(l(a.r, b.r), l(a.g, b.g), l(a.b, b.b), l(a.a, b.a))
}
pub(crate) fn tick_animations(world: &mut World) {
let now = match world.get_resource::<Time>() {
Some(t) => t.elapsed_secs(),
None => return,
};
let Some(mut running) = world.remove_resource::<PfRunningAnimations>() else {
return;
};
if running.0.is_empty() {
world.insert_resource(running);
return;
}
let mut retire: Vec<usize> = Vec::new();
let mut writes: Vec<(Entity, PfAnimTarget, PfValue)> = Vec::new();
let mut stops: Vec<(Entity, PfAnimTarget, Option<PfValue>)> = Vec::new();
for (i, anim) in running.0.iter().enumerate() {
let local = now - anim.begin;
if local < 0.0 {
continue; }
let cycles = local / anim.duration;
let total = match anim.repeat {
PfRepeat::Once => 1.0,
PfRepeat::Count(n) => n,
PfRepeat::Forever => f32::INFINITY,
} * if anim.auto_reverse { 2.0 } else { 1.0 };
let done = cycles >= total;
let cycle_pos = if done { total } else { cycles };
let raw = cycle_pos.fract();
let progress = if anim.auto_reverse {
let phase = (cycle_pos.floor() as i64) % 2;
if done {
0.0
} else if phase == 1 {
1.0 - raw
} else {
raw
}
} else if done {
1.0
} else {
raw
};
let Some(value) = anim.track.sample(progress, anim.duration, anim.easing) else {
continue;
};
if done {
retire.push(i);
match anim.fill {
PfFill::HoldEnd => writes.push((anim.target, anim.prop, value)),
PfFill::Stop => stops.push((anim.target, anim.prop, anim.revert.clone())),
}
} else {
writes.push((anim.target, anim.prop, value));
}
}
for i in retire.into_iter().rev() {
running.0.remove(i);
}
running.0.retain(|a| world.get_entity(a.target).is_ok());
world.insert_resource(running);
for (target, prop, value) in writes {
if world.get_entity(target).is_err() {
continue;
}
apply_anim_value(world, target, prop, value);
}
for (target, prop, revert) in stops {
if world.get_entity(target).is_err() {
continue;
}
revert_anim_target(world, target, prop, revert.as_ref());
}
}
fn apply_anim_value(world: &mut World, target: Entity, prop: PfAnimTarget, value: PfValue) {
match prop {
PfAnimTarget::Store(p) => {
provider::store_and_apply(world, target, p, ValueSource::Animation, Some(value));
}
PfAnimTarget::Transform(f) => {
if let Some(v) = pf_as_f64(&value) {
write_transform_field(world, target, f, v);
}
}
}
}
fn revert_anim_target(
world: &mut World,
target: Entity,
prop: PfAnimTarget,
revert: Option<&PfValue>,
) {
match prop {
PfAnimTarget::Store(p) => {
if let Some(mut store) = world.get_mut::<provider::PfPropertyStore>(target) {
store.clear(p, ValueSource::Animation);
}
provider::apply_effective(world, target, p);
}
PfAnimTarget::Transform(f) => {
let base = revert.and_then(pf_as_f64).unwrap_or(match f {
TransformField::ScaleX | TransformField::ScaleY => 1.0,
_ => 0.0,
});
write_transform_field(world, target, f, base);
}
}
}
pub(crate) fn start_pending_storyboards(world: &mut World) {
let mut q = world.query_filtered::<Entity, With<PfPendingStoryboards>>();
let hosts: Vec<Entity> = q.iter(world).collect();
for host in hosts {
let Some(pending) = world.entity_mut(host).take::<PfPendingStoryboards>() else {
continue;
};
for sb in pending.0 {
begin_storyboard(world, host, host, &sb);
}
}
}
pub fn parse_duration(s: &str) -> Option<f32> {
let t = s.trim();
if let Ok(secs) = t.parse::<f32>() {
return Some(secs);
}
let parts: Vec<&str> = t.split(':').collect();
match parts.as_slice() {
[h, m, sec] => Some(
h.parse::<f32>().ok()? * 3600.0
+ m.parse::<f32>().ok()? * 60.0
+ sec.parse::<f32>().ok()?,
),
[m, sec] => Some(m.parse::<f32>().ok()? * 60.0 + sec.parse::<f32>().ok()?),
_ => None,
}
}
fn stop_tagged(world: &mut World, tag: (Entity, usize)) {
let Some(mut running) = world.remove_resource::<PfRunningAnimations>() else {
return;
};
let mut touched: Vec<(Entity, PfAnimTarget, Option<PfValue>)> = Vec::new();
running.0.retain(|a| {
if a.tag == Some(tag) {
if !touched
.iter()
.any(|(t, p, _)| *t == a.target && *p == a.prop)
{
touched.push((a.target, a.prop, a.revert.clone()));
}
false
} else {
true
}
});
world.insert_resource(running);
for (target, prop, revert) in touched {
if world.get_entity(target).is_err() {
continue;
}
revert_anim_target(world, target, prop, revert.as_ref());
}
}
pub fn go_to_state(world: &mut World, control: Entity, group: &str, state: &str) -> bool {
let Some(states) = world.get::<PfVisualStates>(control) else {
return false;
};
let Some(gi) = states.groups.iter().position(|g| g.name == group) else {
return false;
};
if states.current[gi].as_deref() == Some(state) {
return true; }
let Some(target_state) = states.groups[gi].states.iter().find(|s| s.name == state) else {
return false;
};
let storyboard = target_state.storyboard.clone();
let previously_touched = states.touched.get(gi).cloned().unwrap_or_default();
let old_state = states.current[gi].clone();
let timing = states.groups[gi]
.transitions
.iter()
.filter_map(|t| {
let from_ok = t.from.is_none() || t.from.as_deref() == old_state.as_deref();
let to_ok = t.to.is_none() || t.to.as_deref() == Some(state);
if !from_ok || !to_ok {
return None;
}
let score = u8::from(t.from.is_some()) * 2 + u8::from(t.to.is_some());
Some((score, t.duration, t.easing))
})
.max_by_key(|(score, ..)| *score)
.filter(|(_, d, _)| *d > 0.0)
.map(|(_, d, e)| (d, e));
let mut live: Vec<(Entity, PfAnimTarget, Option<PfValue>)> = Vec::new();
if timing.is_some() {
for (target, prop, _) in &previously_touched {
if world.get_entity(*target).is_err() {
continue;
}
let value = match prop {
PfAnimTarget::Store(p) => world
.get::<provider::PfPropertyStore>(*target)
.and_then(|st| st.effective(*p))
.and_then(|(_, v)| v.clone()),
PfAnimTarget::Transform(f) => {
Some(PfValue::Double(read_transform_field(world, *target, *f)))
}
};
live.push((*target, *prop, value));
}
}
stop_tagged(world, (control, gi));
for (target, prop, revert) in &previously_touched {
if world.get_entity(*target).is_err() {
continue;
}
revert_anim_target(world, *target, *prop, revert.as_ref());
}
let touched = match storyboard {
Some(sb) => {
begin_storyboard_timed(world, control, control, &sb, Some((control, gi)), timing)
}
None => Vec::new(),
};
if let Some((duration, easing)) = timing {
let now = world
.get_resource::<Time>()
.map(|t| t.elapsed_secs())
.unwrap_or(0.0);
let easing = easing.unwrap_or_default();
let normalize = |v: &PfValue| -> Option<PfValue> {
if let Some(d) = pf_as_f64(v) {
return Some(PfValue::Double(d));
}
pf_as_color(v).map(PfValue::Color)
};
if let Some(mut running) = world.get_resource_mut::<PfRunningAnimations>() {
for anim in running.0.iter_mut() {
if anim.tag != Some((control, gi)) {
continue;
}
if let Some((_, _, Some(value))) = live
.iter()
.find(|(t, p, _)| *t == anim.target && *p == anim.prop)
&& let Track::Simple { from, .. } = &mut anim.track
&& let Some(value) = normalize(value)
&& std::mem::discriminant(&value) == std::mem::discriminant(from)
{
*from = value;
}
}
}
for (target, prop, revert) in &previously_touched {
if touched.iter().any(|(t, p, _)| t == target && p == prop) {
continue;
}
let Some((_, _, Some(from))) = live.iter().find(|(t, p, _)| t == target && p == prop)
else {
continue; };
let to = match revert {
Some(v) => Some(v.clone()),
None => match prop {
PfAnimTarget::Store(p) => world
.get::<provider::PfPropertyStore>(*target)
.and_then(|st| st.effective(*p))
.and_then(|(_, v)| v.clone()),
PfAnimTarget::Transform(f) => {
Some(PfValue::Double(read_transform_field(world, *target, *f)))
}
},
};
let (Some(from), Some(to)) = (normalize(from), to.as_ref().and_then(normalize)) else {
continue; };
if std::mem::discriminant(&from) != std::mem::discriminant(&to) {
continue;
}
world
.get_resource_or_insert_with(PfRunningAnimations::default)
.0
.push(Running {
tag: Some((control, gi)),
target: *target,
prop: *prop,
track: Track::Simple { from, to },
revert: revert.clone(),
begin: now,
duration: duration.max(1.0 / 240.0),
repeat: PfRepeat::Once,
auto_reverse: false,
fill: PfFill::Stop,
easing,
});
}
}
if let Some(mut states) = world.get_mut::<PfVisualStates>(control) {
states.current[gi] = Some(state.to_string());
if states.touched.len() <= gi {
states.touched.resize(gi + 1, Vec::new());
}
states.touched[gi] = touched;
}
true
}
pub(crate) fn drive_visual_states(world: &mut World) {
let mut q = world.query_filtered::<Entity, With<PfVisualStates>>();
let controls: Vec<Entity> = q.iter(world).collect();
for control in controls {
let disabled = world
.get::<bevy::ui::InteractionDisabled>(control)
.is_some();
let interaction = world.get::<Interaction>(control).copied();
let checked = world.get::<bevy::ui::Checked>(control).is_some();
let declares = |name: &str| {
world.get::<PfVisualStates>(control).is_some_and(|vs| {
vs.groups
.iter()
.any(|g| g.states.iter().any(|s| s.name == name))
})
};
let hover_state = if declares("PointerOver") {
if declares("MouseOver") {
warn!(
"CommonStates declares both MouseOver and PointerOver; entering PointerOver and leaving MouseOver unreachable"
);
}
"PointerOver"
} else {
"MouseOver"
};
let common = if disabled {
"Disabled"
} else {
match interaction {
Some(Interaction::Pressed) => "Pressed",
Some(Interaction::Hovered) => hover_state,
_ => "Normal",
}
};
let check = if checked { "Checked" } else { "Unchecked" };
let focused = world
.get_resource::<bevy::input_focus::InputFocus>()
.and_then(|f| f.get())
.is_some_and(|f| {
let mut cursor = f;
loop {
if cursor == control {
break true;
}
match world.get::<ChildOf>(cursor) {
Some(p) => cursor = p.parent(),
None => break false,
}
}
});
let focus = if focused { "Focused" } else { "Unfocused" };
go_to_state(world, control, "CommonStates", common);
go_to_state(world, control, "CheckStates", check);
go_to_state(world, control, "FocusStates", focus);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn easing_endpoints_and_shape() {
use EasingMode::*;
let curves = [
PfEasing::Linear,
PfEasing::Quadratic(In),
PfEasing::Cubic(Out),
PfEasing::Sine(InOut),
PfEasing::Circle(Out),
PfEasing::Bounce(Out),
PfEasing::Exponential(In),
PfEasing::AccelDecel {
accel: 0.3,
decel: 0.3,
},
];
for c in curves {
assert!(c.ease(0.0).abs() < 1e-4, "{c:?} starts at 0");
assert!((c.ease(1.0) - 1.0).abs() < 1e-3, "{c:?} ends at 1");
}
assert!(PfEasing::Cubic(Out).ease(0.25) > 0.25);
assert!(PfEasing::Cubic(In).ease(0.25) < 0.25);
}
#[test]
fn durations_parse() {
assert_eq!(parse_duration("0:0:0.5"), Some(0.5));
assert_eq!(parse_duration("0:1:2"), Some(62.0));
assert_eq!(parse_duration("1:0:0"), Some(3600.0));
assert_eq!(parse_duration("0.25"), Some(0.25));
assert_eq!(parse_duration("bogus"), None);
}
}