use std::collections::HashMap;
use std::sync::Arc;
use bevy_pf_xaml::markup::MarkupValue;
use bevy_pf_xaml::value as v;
use bevy_pf_xaml::{XamlNode, XamlValue};
use crate::error::PfError;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ResourceKey {
Explicit(String),
Type(String),
}
#[derive(Debug, Clone)]
pub enum PfValue {
String(String),
Double(f64),
Bool(bool),
Color(v::PfColor),
Brush(v::PfBrush),
Thickness(v::Thickness),
CornerRadius(v::CornerRadius),
Geometry(bevy_pf_xaml::geometry::PathData),
Transform(v::PfTransform),
Style(Arc<PfStyle>),
Template(Arc<XamlNode>),
ControlTemplate(Arc<PfControlTemplate>),
Storyboard(Arc<crate::animation::PfStoryboard>),
}
#[derive(Debug, Clone)]
pub struct PfControlTemplate {
pub target_type: Option<String>,
pub root: Arc<XamlNode>,
pub triggers: Vec<PfTrigger>,
pub state_groups: Vec<crate::animation::PfVisualStateGroup>,
}
#[derive(Debug, Clone, Default)]
pub struct PfStyle {
pub target_type: Option<String>,
pub selector: Option<Arc<crate::selector::Sel>>,
pub setters: Vec<PfSetter>,
pub triggers: Vec<PfTrigger>,
pub event_triggers: Vec<crate::animation::PfEventTrigger>,
}
#[derive(Debug, Clone)]
pub struct PfTrigger {
pub conditions: Vec<PfTriggerCondition>,
pub setters: Vec<PfSetter>,
pub enter_storyboards: Vec<Arc<crate::animation::PfStoryboard>>,
pub exit_storyboards: Vec<Arc<crate::animation::PfStoryboard>>,
}
#[derive(Debug, Clone)]
pub enum PfTriggerCondition {
Property {
property: String,
value: PfTriggerValue,
},
Data { path: String, value: PfTriggerValue },
VisualState { group: String, state: String },
}
#[derive(Debug, Clone, PartialEq)]
pub enum PfTriggerValue {
Text(String),
Null,
}
#[derive(Debug, Clone)]
pub struct PfSetter {
pub owner: Option<String>,
pub property: String,
pub value: PfSetterValue,
pub target_name: Option<String>,
}
#[derive(Debug, Clone)]
pub enum PfSetterValue {
Literal(String),
Resource(ResourceKey),
DynamicResource(ResourceKey),
Null,
Value(PfValue),
TemplatedParent(String),
AppTheme(RawThemeArms),
Selector(crate::device::SelectorKind, RawSelectorArms),
}
pub type ResourceDictionary = HashMap<ResourceKey, PfValue>;
#[derive(Debug, Default, Clone)]
pub struct ResourceScopes {
stack: Vec<Arc<ResourceDictionary>>,
pub app: Option<Arc<ResourceDictionary>>,
}
impl ResourceScopes {
pub fn push(&mut self, dict: Arc<ResourceDictionary>) {
self.stack.push(dict);
}
pub fn pop(&mut self) {
self.stack.pop();
}
pub fn lookup(&self, key: &ResourceKey) -> Option<&PfValue> {
self.stack
.iter()
.rev()
.find_map(|d| d.get(key))
.or_else(|| self.app.as_ref().and_then(|d| d.get(key)))
}
pub fn lookup_str(&self, key: &str) -> Option<&PfValue> {
self.lookup(&ResourceKey::Explicit(key.to_string()))
}
pub fn implicit_style(&self, type_name: &str) -> Option<Arc<PfStyle>> {
match self.lookup(&ResourceKey::Type(type_name.to_string())) {
Some(PfValue::Style(s)) => Some(s.clone()),
_ => None,
}
}
pub fn isolate_stack(&mut self) -> Vec<Arc<ResourceDictionary>> {
std::mem::take(&mut self.stack)
}
pub fn restore_stack(&mut self, stack: Vec<Arc<ResourceDictionary>>) {
self.stack = stack;
}
}
pub fn static_resource_key(ext: &bevy_pf_xaml::MarkupExtension) -> Result<ResourceKey, PfError> {
match ext.positional.first() {
Some(MarkupValue::Str(s)) => Ok(ResourceKey::Explicit(s.clone())),
Some(MarkupValue::Extension(inner)) if inner.name == "x:Type" || inner.name == "Type" => {
let ty = inner
.first_positional_str()
.ok_or_else(|| PfError::resource("x:Type missing a type name"))?;
let ty = ty.rsplit(':').next().unwrap_or(ty);
Ok(ResourceKey::Type(ty.to_string()))
}
_ => Err(PfError::resource(format!("unsupported {} key", ext.name))),
}
}
#[derive(Debug, Clone)]
pub enum RawArm {
Text(String),
Static(ResourceKey),
Dynamic(ResourceKey),
Null,
}
#[derive(Debug, Clone, Default)]
pub struct RawThemeArms {
pub light: Option<RawArm>,
pub dark: Option<RawArm>,
pub default: Option<RawArm>,
}
impl RawThemeArms {
pub fn pick(&self, theme: crate::app_theme::AppTheme) -> Option<&RawArm> {
match theme {
crate::app_theme::AppTheme::Dark => self.dark.as_ref().or(self.default.as_ref()),
_ => self.light.as_ref().or(self.default.as_ref()),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct RawSelectorArms {
pub arms: Vec<(String, RawArm)>,
pub default: Option<RawArm>,
}
impl RawSelectorArms {
pub fn pick(
&self,
device: &crate::device::PfDevice,
kind: crate::device::SelectorKind,
) -> Option<&RawArm> {
self.arms
.iter()
.find(|(name, _)| device.matches(kind, name))
.map(|(_, arm)| arm)
.or(self.default.as_ref())
}
}
pub fn parse_selector_arms(
ext: &bevy_pf_xaml::MarkupExtension,
kind: crate::device::SelectorKind,
) -> Result<RawSelectorArms, PfError> {
let mut arms = RawSelectorArms::default();
for (key, value) in &ext.named {
if key == "Default" {
arms.default = Some(theme_arm(value, kind.extension_name())?);
continue;
}
if !kind.arm_names().contains(&key.as_str()) {
return Err(PfError::resource(format!(
"{} has no `{key}` argument; expected one of {} or Default",
kind.extension_name(),
kind.arm_names().join(", ")
)));
}
arms.arms
.push((key.clone(), theme_arm(value, kind.extension_name())?));
}
if let Some(positional) = ext.positional.first() {
if arms.default.is_some() {
return Err(PfError::resource(format!(
"{} got Default both positionally and by name",
kind.extension_name()
)));
}
arms.default = Some(theme_arm(positional, kind.extension_name())?);
}
let holds_a_value = arms
.arms
.iter()
.map(|(_, a)| a)
.chain(arms.default.iter())
.any(|a| !matches!(a, RawArm::Null));
if !holds_a_value {
return Err(PfError::resource(format!(
"{} requires a value to be specified for at least one {} or Default",
kind.extension_name(),
match kind {
crate::device::SelectorKind::Platform => "platform",
crate::device::SelectorKind::Idiom => "idiom",
}
)));
}
arms.arms
.sort_by_key(|(name, _)| usize::from(name == "MacCatalyst"));
Ok(arms)
}
fn theme_arm(value: &MarkupValue, owner: &str) -> Result<RawArm, PfError> {
match value {
MarkupValue::Str(s) => Ok(RawArm::Text(s.clone())),
MarkupValue::Extension(inner) => match inner.name.as_str() {
"StaticResource" => Ok(RawArm::Static(static_resource_key(inner)?)),
"DynamicResource" => Ok(RawArm::Dynamic(static_resource_key(inner)?)),
"x:Null" | "Null" => Ok(RawArm::Null),
other => Err(PfError::resource(format!(
"{owner} arm `{{{other}}}` is not supported"
))),
},
}
}
pub fn parse_app_theme_arms(ext: &bevy_pf_xaml::MarkupExtension) -> Result<RawThemeArms, PfError> {
let arm = |value: &MarkupValue| theme_arm(value, "AppThemeBinding");
let mut arms = RawThemeArms::default();
for (key, value) in &ext.named {
match key.as_str() {
"Light" => arms.light = Some(arm(value)?),
"Dark" => arms.dark = Some(arm(value)?),
"Default" => arms.default = Some(arm(value)?),
other => {
return Err(PfError::resource(format!(
"AppThemeBinding has no `{other}` argument"
)));
}
}
}
if let Some(positional) = ext.positional.first() {
if arms.default.is_some() {
return Err(PfError::resource(
"AppThemeBinding got Default both positionally and by name",
));
}
arms.default = Some(arm(positional)?);
}
let holds_a_value = [&arms.light, &arms.dark, &arms.default]
.into_iter()
.any(|a| matches!(a, Some(a) if !matches!(a, RawArm::Null)));
if !holds_a_value {
return Err(PfError::resource(
"AppThemeBinding requires a non-null value for at least one theme or Default",
));
}
Ok(arms)
}
pub fn parse_resource_dictionary<'a>(
nodes: impl Iterator<Item = &'a XamlNode>,
scopes: &ResourceScopes,
warnings: &mut Vec<String>,
) -> ResourceDictionary {
let mut dict = ResourceDictionary::new();
parse_resource_entries_into(nodes, scopes, &mut dict, warnings);
dict
}
pub fn parse_resource_entries_into<'a>(
nodes: impl Iterator<Item = &'a XamlNode>,
scopes: &ResourceScopes,
dict: &mut ResourceDictionary,
warnings: &mut Vec<String>,
) {
for node in nodes {
match parse_resource_entry(node, scopes, dict, warnings) {
Ok(Some((key, value))) => {
dict.insert(key, value);
}
Ok(None) => {}
Err(e) => warnings.push(format!(
"{}: skipped resource `{}`: {e}",
node.pos, node.name
)),
}
}
}
fn parse_resource_entry(
node: &XamlNode,
scopes: &ResourceScopes,
local: &ResourceDictionary,
warnings: &mut Vec<String>,
) -> Result<Option<(ResourceKey, PfValue)>, PfError> {
let value = match parse_resource_value(node, scopes, local, warnings)? {
Some(v) => v,
None => return Ok(None),
};
let key = if let Some(k) = &node.x_key {
let trimmed = k.trim();
if let Some(inner) = trimmed
.strip_prefix('{')
.and_then(|r| r.strip_suffix('}'))
.map(str::trim)
.filter(|r| r.starts_with("x:Type ") || r.starts_with("Type "))
{
let ty = inner.split_whitespace().nth(1).unwrap_or_default();
let ty = ty.rsplit(':').next().unwrap_or(ty);
if ty.is_empty() {
return Err(PfError::resource("x:Key {x:Type} needs a type name"));
}
ResourceKey::Type(ty.to_string())
} else if trimmed.starts_with('{') {
warnings.push(format!(
"{}: markup-extension x:Key `{k}` is not supported (only {{x:Type ...}}); used literally",
node.pos
));
ResourceKey::Explicit(k.clone())
} else {
ResourceKey::Explicit(k.clone())
}
} else if let PfValue::Style(style) = &value {
match &style.target_type {
Some(t) => ResourceKey::Type(t.clone()),
None => return Err(PfError::resource("Style needs x:Key or TargetType")),
}
} else if let PfValue::Template(_) = &value {
let ty = match node.attribute("DataType") {
Some(XamlValue::Str(s)) => Some(s.as_str()),
Some(XamlValue::Extension(ext)) if ext.name == "x:Type" || ext.name == "Type" => {
ext.first_positional_str()
}
_ => None,
};
match ty {
Some(t) => {
let t = t.rsplit(':').next().unwrap_or(t);
ResourceKey::Type(t.to_string())
}
None => return Err(PfError::resource("DataTemplate needs x:Key or DataType")),
}
} else {
return Err(PfError::resource("resource needs x:Key"));
};
Ok(Some((key, value)))
}
pub fn parse_resource_value(
node: &XamlNode,
scopes: &ResourceScopes,
local: &ResourceDictionary,
warnings: &mut Vec<String>,
) -> Result<Option<PfValue>, PfError> {
let text = node.text_content().unwrap_or_default();
let value = match node.name.as_str() {
"Double" => PfValue::Double(
text.trim()
.parse()
.map_err(|e| PfError::resource(format!("bad x:Double: {e}")))?,
),
"String" => PfValue::String(text),
"Boolean" => PfValue::Bool(text.trim().eq_ignore_ascii_case("true")),
"Int32" | "Int64" => PfValue::Double(
text.trim()
.parse::<i64>()
.map_err(|e| PfError::resource(format!("bad integer: {e}")))? as f64,
),
"SolidColorBrush" => {
let color = match node.attribute("Color") {
Some(XamlValue::Str(s)) => s.parse::<v::PfColor>()?,
Some(XamlValue::Extension(ext)) => resolve_color_resource(ext, scopes, local)?,
None => text.trim().parse::<v::PfColor>()?,
};
let color = match brush_opacity(node) {
Some(o) => scale_alpha(color, o),
None => color,
};
PfValue::Brush(v::PfBrush::Solid(color))
}
"Color" => PfValue::Color(text.trim().parse()?),
"Thickness" => PfValue::Thickness(text.trim().parse()?),
"CornerRadius" => PfValue::CornerRadius(text.trim().parse()?),
"LinearGradientBrush" => {
PfValue::Brush(parse_linear_gradient(node, scopes, local, warnings)?)
}
"RadialGradientBrush" => {
PfValue::Brush(parse_radial_gradient(node, scopes, local, warnings)?)
}
"Style" => PfValue::Style(Arc::new(parse_style(node, scopes, local, warnings)?)),
"DataTemplate" => {
let mut roots = node.child_elements();
let root = roots
.next()
.ok_or_else(|| PfError::resource("DataTemplate needs a root element"))?;
if roots.next().is_some() {
return Err(PfError::resource("DataTemplate must have one root element"));
}
PfValue::Template(Arc::new(root.clone()))
}
"Storyboard" => PfValue::Storyboard(Arc::new(parse_storyboard(node, warnings)?)),
"ControlTemplate" => {
let target_type = match node.attribute("TargetType") {
Some(XamlValue::Str(t)) => Some(t.rsplit(':').next().unwrap_or(t).to_string()),
Some(XamlValue::Extension(ext)) if ext.name == "x:Type" || ext.name == "Type" => {
ext.first_positional_str()
.map(|t| t.rsplit(':').next().unwrap_or(t).to_string())
}
_ => None,
};
let mut roots = node.child_elements();
let root = roots
.next()
.ok_or_else(|| PfError::resource("ControlTemplate needs a root element"))?;
if roots.next().is_some() {
return Err(PfError::resource(
"ControlTemplate must have one root element",
));
}
let mut triggers = Vec::new();
if let Some(pe) = node.property_element("Triggers") {
for trigger in pe.elements() {
match parse_trigger(trigger, scopes, local, warnings, true) {
Ok(Some(t)) => triggers.push(t),
Ok(None) => {}
Err(e) => {
warnings.push(format!("{}: skipped trigger: {e}", trigger.pos));
}
}
}
}
let (state_groups, state_triggers) = root
.property_elements
.iter()
.find(|p| p.name == "VisualStateGroups")
.map(|pe| parse_visual_state_groups(pe, scopes, local, warnings))
.transpose()?
.unwrap_or_default();
triggers.extend(state_triggers);
PfValue::ControlTemplate(Arc::new(PfControlTemplate {
target_type,
root: Arc::new(root.clone()),
triggers,
state_groups,
}))
}
"Geometry" => PfValue::Geometry(bevy_pf_xaml::geometry::parse_path_data(text.trim())?),
"PathGeometry" | "StreamGeometry" | "EllipseGeometry" | "RectangleGeometry"
| "LineGeometry" | "GeometryGroup" => {
PfValue::Geometry(parse_geometry_element(node, warnings)?)
}
"TranslateTransform" | "ScaleTransform" | "RotateTransform" | "TransformGroup" => {
PfValue::Transform(parse_transform_element(node, warnings)?)
}
other => {
warnings.push(format!(
"{}: resource type `{other}` is not supported yet",
node.pos
));
return Ok(None);
}
};
Ok(Some(value))
}
fn resolve_color_resource(
ext: &bevy_pf_xaml::MarkupExtension,
scopes: &ResourceScopes,
local: &ResourceDictionary,
) -> Result<v::PfColor, PfError> {
if ext.name != "StaticResource" && ext.name != "DynamicResource" {
return Err(PfError::resource(format!(
"Color extension `{{{}}}` not supported",
ext.name
)));
}
let key = static_resource_key(ext)?;
match local.get(&key).or_else(|| scopes.lookup(&key)) {
Some(PfValue::Color(c)) => Ok(*c),
Some(PfValue::Brush(v::PfBrush::Solid(c))) => Ok(*c),
Some(PfValue::String(s)) => Ok(s.parse()?),
Some(_) => Err(PfError::resource("resource is not a color")),
None => Err(PfError::resource(format!(
"color resource `{key:?}` not found"
))),
}
}
fn attr_parse<T: std::str::FromStr<Err = bevy_pf_xaml::XamlError>>(
node: &XamlNode,
name: &str,
) -> Result<Option<T>, PfError> {
match node.attribute(name) {
Some(XamlValue::Str(s)) => Ok(Some(s.parse::<T>()?)),
_ => Ok(None),
}
}
fn parse_gradient_stops(
node: &XamlNode,
scopes: &ResourceScopes,
local: &ResourceDictionary,
) -> Result<Vec<v::GradientStop>, PfError> {
let mut stops = Vec::new();
let from_property = node
.property_element("GradientStops")
.map(|p| p.elements().collect::<Vec<_>>());
let elems: Vec<&XamlNode> = match &from_property {
Some(elems) => elems.clone(),
None => node.child_elements().collect(),
};
for stop in elems {
let items: Vec<&XamlNode> = if stop.name == "GradientStopCollection" {
stop.child_elements().collect()
} else {
vec![stop]
};
for item in items {
if item.name != "GradientStop" {
continue;
}
let color: v::PfColor = match item.attribute("Color") {
Some(XamlValue::Str(s)) => s.parse()?,
Some(XamlValue::Extension(ext)) => resolve_color_resource(ext, scopes, local)?,
None => return Err(PfError::resource("GradientStop needs Color")),
};
let offset: f64 = match item.attribute("Offset") {
Some(XamlValue::Str(s)) => s.trim().parse().unwrap_or(0.0),
_ => 0.0,
};
stops.push(v::GradientStop {
color,
offset: offset as f32,
});
}
}
if stops.is_empty() {
return Err(PfError::resource("gradient brush has no stops"));
}
Ok(stops)
}
fn brush_opacity(node: &XamlNode) -> Option<f32> {
match node.attribute("Opacity") {
Some(XamlValue::Str(s)) => s.trim().parse::<f32>().ok().map(|o| o.clamp(0.0, 1.0)),
_ => None,
}
}
fn scale_alpha(color: v::PfColor, opacity: f32) -> v::PfColor {
v::PfColor {
a: ((color.a as f32) * opacity).round().clamp(0.0, 255.0) as u8,
..color
}
}
fn gradient_stops_with_opacity(
node: &XamlNode,
scopes: &ResourceScopes,
local: &ResourceDictionary,
) -> Result<Vec<v::GradientStop>, PfError> {
let stops = parse_gradient_stops(node, scopes, local)?;
let Some(opacity) = brush_opacity(node) else {
return Ok(stops);
};
Ok(stops
.into_iter()
.map(|s| v::GradientStop {
color: scale_alpha(s.color, opacity),
..s
})
.collect())
}
fn warn_unsupported_gradient_attrs(node: &XamlNode, warnings: &mut Vec<String>) {
const DROPPED: [(&str, &str); 5] = [
("SpreadMethod", "Reflect and Repeat render as Pad"),
(
"MappingMode",
"coordinates are always relative to the bounding box",
),
(
"GradientOrigin",
"the radial focal point is ignored; the gradient stays centred",
),
("ColorInterpolationMode", "stops always interpolate in sRGB"),
("RelativeTransform", "brush transforms are not applied"),
];
for (attr, effect) in DROPPED {
if node.attribute(attr).is_some() {
warnings.push(format!("{}: {attr} is not supported — {effect}", node.name));
}
}
let rx = node.attribute("RadiusX");
let ry = node.attribute("RadiusY");
if let (Some(XamlValue::Str(a)), Some(XamlValue::Str(b))) = (rx, ry)
&& a.trim() != b.trim()
{
warnings.push(format!(
"{}: RadiusX and RadiusY differ, but an elliptical radial gradient is \
rendered as a circle on every backend",
node.name
));
}
}
fn parse_linear_gradient(
node: &XamlNode,
scopes: &ResourceScopes,
local: &ResourceDictionary,
warnings: &mut Vec<String>,
) -> Result<v::PfBrush, PfError> {
warn_unsupported_gradient_attrs(node, warnings);
let start: v::Point = attr_parse(node, "StartPoint")?.unwrap_or(v::Point::new(0.0, 0.0));
let end: v::Point = attr_parse(node, "EndPoint")?.unwrap_or(v::Point::new(1.0, 1.0));
Ok(v::PfBrush::LinearGradient {
start,
end,
stops: gradient_stops_with_opacity(node, scopes, local)?,
})
}
fn parse_radial_gradient(
node: &XamlNode,
scopes: &ResourceScopes,
local: &ResourceDictionary,
warnings: &mut Vec<String>,
) -> Result<v::PfBrush, PfError> {
warn_unsupported_gradient_attrs(node, warnings);
let center: v::Point = attr_parse(node, "Center")?.unwrap_or(v::Point::new(0.5, 0.5));
let radius_x = match node.attribute("RadiusX") {
Some(XamlValue::Str(s)) => s.trim().parse().unwrap_or(0.5),
_ => 0.5,
};
let radius_y = match node.attribute("RadiusY") {
Some(XamlValue::Str(s)) => s.trim().parse().unwrap_or(0.5),
_ => 0.5,
};
Ok(v::PfBrush::RadialGradient {
center,
radius_x,
radius_y,
stops: gradient_stops_with_opacity(node, scopes, local)?,
})
}
pub fn parse_geometry_element(
node: &XamlNode,
warnings: &mut Vec<String>,
) -> Result<bevy_pf_xaml::geometry::PathData, PfError> {
use bevy_pf_xaml::geometry::parse_path_data;
let data = match node.name.as_str() {
"StreamGeometry" => {
let text = node.text_content().unwrap_or_default();
parse_path_data(text.trim())?
}
"PathGeometry" => {
if let Some(XamlValue::Str(figures)) = node.attribute("Figures") {
parse_path_data(figures)?
} else {
parse_structured_path(node, warnings)?
}
}
"LineGeometry" => {
let start: v::Point =
attr_parse(node, "StartPoint")?.unwrap_or(v::Point::new(0.0, 0.0));
let end: v::Point = attr_parse(node, "EndPoint")?.unwrap_or(v::Point::new(0.0, 0.0));
parse_path_data(&format!("M {},{} L {},{}", start.x, start.y, end.x, end.y))?
}
"RectangleGeometry" => {
let rect: v::Rect = attr_parse(node, "Rect")?.unwrap_or_default();
parse_path_data(&format!(
"M {},{} L {},{} L {},{} L {},{} Z",
rect.x,
rect.y,
rect.x + rect.width,
rect.y,
rect.x + rect.width,
rect.y + rect.height,
rect.x,
rect.y + rect.height,
))?
}
"EllipseGeometry" => {
let center: v::Point = attr_parse(node, "Center")?.unwrap_or(v::Point::new(0.0, 0.0));
let rx = match node.attribute("RadiusX") {
Some(XamlValue::Str(s)) => s.trim().parse().unwrap_or(0.0),
_ => 0.0,
};
let ry = match node.attribute("RadiusY") {
Some(XamlValue::Str(s)) => s.trim().parse().unwrap_or(0.0),
_ => 0.0,
};
parse_path_data(&format!(
"M {},{} A {rx},{ry} 0 1 0 {},{} A {rx},{ry} 0 1 0 {},{} Z",
center.x - rx,
center.y,
center.x + rx,
center.y,
center.x - rx,
center.y,
))?
}
"GeometryGroup" => {
let mut merged = bevy_pf_xaml::geometry::PathData::default();
if let Some(XamlValue::Str(rule)) = node.attribute("FillRule")
&& rule.eq_ignore_ascii_case("nonzero")
{
merged.fill_rule = bevy_pf_xaml::geometry::FillRule::NonZero;
}
let children_pe = node
.property_element("Children")
.map(|p| p.elements().collect::<Vec<_>>());
let children: Vec<&XamlNode> = match &children_pe {
Some(elems) => elems.clone(),
None => node.child_elements().collect(),
};
for child in children {
match parse_geometry_element(child, warnings) {
Ok(sub) => merged.figures.extend(sub.figures),
Err(e) => warnings.push(format!("{}: {e}", child.pos)),
}
}
merged
}
other => {
return Err(PfError::resource(format!(
"geometry `{other}` is not supported yet"
)));
}
};
Ok(data)
}
fn parse_structured_path(
node: &XamlNode,
warnings: &mut Vec<String>,
) -> Result<bevy_pf_xaml::geometry::PathData, PfError> {
use bevy_pf_xaml::geometry::{PathData, PathFigure, PathSegment};
let mut data = PathData::default();
if let Some(XamlValue::Str(rule)) = node.attribute("FillRule")
&& rule.eq_ignore_ascii_case("nonzero")
{
data.fill_rule = bevy_pf_xaml::geometry::FillRule::NonZero;
}
let figures_pe = node
.property_element("Figures")
.map(|p| p.elements().collect::<Vec<_>>());
let figures: Vec<&XamlNode> = match &figures_pe {
Some(elems) => elems.clone(),
None => node.child_elements().collect(),
};
for fig in figures {
if fig.name != "PathFigure" {
warnings.push(format!(
"{}: unexpected `{}` in PathGeometry",
fig.pos, fig.name
));
continue;
}
let start: v::Point = attr_parse(fig, "StartPoint")?.unwrap_or_default();
let closed = matches!(
fig.attribute("IsClosed"),
Some(XamlValue::Str(s)) if s.eq_ignore_ascii_case("true")
);
let mut figure = PathFigure {
start,
segments: Vec::new(),
closed,
};
let segs_pe = fig
.property_element("Segments")
.map(|p| p.elements().collect::<Vec<_>>());
let segs: Vec<&XamlNode> = match &segs_pe {
Some(elems) => elems.clone(),
None => fig.child_elements().collect(),
};
for seg in segs {
let point = |name: &str| -> Result<v::Point, PfError> {
attr_parse::<v::Point>(seg, name)?
.ok_or_else(|| PfError::resource(format!("{} needs {name}", seg.name)))
};
let points = || -> Result<Vec<v::Point>, PfError> {
match seg.attribute("Points") {
Some(XamlValue::Str(s)) => Ok(v::parse_points(s)?),
_ => Err(PfError::resource(format!("{} needs Points", seg.name))),
}
};
match seg.name.as_str() {
"LineSegment" => figure.segments.push(PathSegment::Line(point("Point")?)),
"PolyLineSegment" => {
for p in points()? {
figure.segments.push(PathSegment::Line(p));
}
}
"BezierSegment" => figure.segments.push(PathSegment::Cubic(
point("Point1")?,
point("Point2")?,
point("Point3")?,
)),
"PolyBezierSegment" => {
for chunk in points()?.chunks_exact(3) {
figure
.segments
.push(PathSegment::Cubic(chunk[0], chunk[1], chunk[2]));
}
}
"QuadraticBezierSegment" => figure
.segments
.push(PathSegment::Quadratic(point("Point1")?, point("Point2")?)),
"PolyQuadraticBezierSegment" => {
for chunk in points()?.chunks_exact(2) {
figure
.segments
.push(PathSegment::Quadratic(chunk[0], chunk[1]));
}
}
"ArcSegment" => {
let size: v::Size = attr_parse(seg, "Size")?.unwrap_or_default();
let rotation = match seg.attribute("RotationAngle") {
Some(XamlValue::Str(s)) => s.trim().parse().unwrap_or(0.0),
_ => 0.0,
};
let large_arc = matches!(
seg.attribute("IsLargeArc"),
Some(XamlValue::Str(s)) if s.eq_ignore_ascii_case("true")
);
let sweep = matches!(
seg.attribute("SweepDirection"),
Some(XamlValue::Str(s)) if s.eq_ignore_ascii_case("clockwise")
);
figure.segments.push(PathSegment::Arc {
radii: v::Point::new(size.width, size.height),
rotation,
large_arc,
sweep,
to: point("Point")?,
});
}
other => warnings.push(format!(
"{}: path segment `{other}` is not supported yet",
seg.pos
)),
}
}
data.figures.push(figure);
}
if data.figures.is_empty() {
return Err(PfError::resource("PathGeometry has no figures"));
}
Ok(data)
}
pub fn parse_transform_element(
node: &XamlNode,
warnings: &mut Vec<String>,
) -> Result<v::PfTransform, PfError> {
let attr_f32 = |name: &str, default: f32| -> f32 {
match node.attribute(name) {
Some(XamlValue::Str(s)) => s.trim().parse().unwrap_or(default),
_ => default,
}
};
let t = match node.name.as_str() {
"TranslateTransform" => v::PfTransform {
translate_x: attr_f32("X", 0.0),
translate_y: attr_f32("Y", 0.0),
..Default::default()
},
"ScaleTransform" => v::PfTransform {
scale_x: attr_f32("ScaleX", 1.0),
scale_y: attr_f32("ScaleY", 1.0),
..Default::default()
},
"RotateTransform" => v::PfTransform {
rotate_deg: attr_f32("Angle", 0.0),
..Default::default()
},
"SkewTransform" | "MatrixTransform" => v::PfTransform::default(),
"TransformGroup" => {
let children_pe = node
.property_element("Children")
.map(|p| p.elements().collect::<Vec<_>>());
let children: Vec<&XamlNode> = match &children_pe {
Some(elems) => elems.clone(),
None => node.child_elements().collect(),
};
let mut combined = v::PfTransform::default();
for child in children {
match parse_transform_element(child, warnings) {
Ok(t) => combined = combined.compose(&t),
Err(e) => warnings.push(format!("{}: {e}", child.pos)),
}
}
combined
}
other => {
return Err(PfError::resource(format!(
"transform `{other}` is not supported yet"
)));
}
};
Ok(t)
}
pub fn parse_style(
node: &XamlNode,
scopes: &ResourceScopes,
local: &ResourceDictionary,
warnings: &mut Vec<String>,
) -> Result<PfStyle, PfError> {
let mut style = PfStyle::default();
if let Some(XamlValue::Str(src)) = node.attribute("Selector") {
match crate::selector::parse(src) {
Ok(sel) => style.selector = Some(Arc::new(sel)),
Err(e) => return Err(PfError::resource(format!("{e}"))),
}
}
match node.attribute("TargetType") {
Some(XamlValue::Str(s)) => {
let t = s.rsplit(':').next().unwrap_or(s);
style.target_type = Some(t.to_string());
}
Some(XamlValue::Extension(ext)) if ext.name == "x:Type" || ext.name == "Type" => {
if let Some(t) = ext.first_positional_str() {
let t = t.rsplit(':').next().unwrap_or(t);
style.target_type = Some(t.to_string());
}
}
_ => {}
}
if let Some(XamlValue::Extension(ext)) = node.attribute("BasedOn")
&& ext.name == "StaticResource"
{
let key = static_resource_key(ext)?;
let base = local.get(&key).or_else(|| scopes.lookup(&key));
match base {
Some(PfValue::Style(base)) => {
style.setters.extend(base.setters.iter().cloned());
style.triggers.extend(base.triggers.iter().cloned());
if style.target_type.is_none() {
style.target_type = base.target_type.clone();
}
}
_ => warnings.push(format!(
"{}: BasedOn resource not found (must be defined before use)",
node.pos
)),
}
}
let from_property = node
.property_element("Setters")
.map(|p| p.elements().collect::<Vec<_>>());
let setter_elems: Vec<&XamlNode> = match &from_property {
Some(elems) => elems.clone(),
None => node.child_elements().collect(),
};
for setter in setter_elems {
match setter.name.as_str() {
"Setter" => match parse_setter(setter, scopes, local, warnings) {
Ok(s) => style.setters.push(s),
Err(e) => warnings.push(format!("{}: skipped setter: {e}", setter.pos)),
},
"EventSetter" => {
warnings.push(format!("{}: EventSetter is not supported yet", setter.pos))
}
other => warnings.push(format!(
"{}: `{other}` in Style is not supported yet",
setter.pos
)),
}
}
if let Some(triggers_pe) = node.property_element("Triggers") {
for trigger in triggers_pe.elements() {
if trigger.name == "EventTrigger" {
if let Some(et) = parse_event_trigger(trigger, scopes, local, warnings) {
style.event_triggers.push(et);
}
continue;
}
match parse_trigger(trigger, scopes, local, warnings, false) {
Ok(Some(t)) => style.triggers.push(t),
Ok(None) => {}
Err(e) => warnings.push(format!("{}: skipped trigger: {e}", trigger.pos)),
}
}
}
Ok(style)
}
fn parse_key_frames(
node: &XamlNode,
color: bool,
warnings: &mut Vec<String>,
) -> Vec<crate::animation::PfKeyFrame> {
use crate::animation::{PfKeyFrame, PfKeyInterp, PfKeyValue, parse_duration};
let mut frames = Vec::new();
for frame in node.child_elements() {
let interp = if frame.name.starts_with("Discrete") {
PfKeyInterp::Discrete
} else if frame.name.starts_with("Linear") {
PfKeyInterp::Linear
} else if frame.name.starts_with("Easing") {
let easing = frame
.property_element("EasingFunction")
.and_then(|pe| pe.single_element())
.map(|el| parse_easing_element(el, warnings))
.unwrap_or(crate::animation::PfEasing::Linear);
PfKeyInterp::Easing(easing)
} else if frame.name.starts_with("Spline") {
let nums: Vec<f32> = match frame.attribute("KeySpline") {
Some(XamlValue::Str(s)) => s
.split([',', ' '])
.filter(|t| !t.is_empty())
.filter_map(|t| t.trim().parse().ok())
.collect(),
_ => Vec::new(),
};
if nums.len() == 4 {
PfKeyInterp::Spline {
x1: nums[0],
y1: nums[1],
x2: nums[2],
y2: nums[3],
}
} else {
warnings.push(format!("{}: bad KeySpline; Linear used", frame.pos));
PfKeyInterp::Linear
}
} else {
warnings.push(format!(
"{}: keyframe `{}` is not supported; skipped",
frame.pos, frame.name
));
continue;
};
let time = match frame.attribute("KeyTime") {
Some(XamlValue::Str(t)) if t.eq_ignore_ascii_case("uniform") => None,
Some(XamlValue::Str(t)) => match parse_duration(t) {
Some(secs) => Some(secs),
None => {
warnings.push(format!(
"{}: KeyTime `{t}` is not supported (Paced/percent); Uniform used",
frame.pos
));
None
}
},
_ => None,
};
let value = match frame.attribute("Value") {
Some(XamlValue::Str(text)) if color => match text.parse::<v::PfColor>() {
Ok(c) => PfKeyValue::Color(c),
Err(_) => {
warnings.push(format!("{}: bad keyframe color `{text}`", frame.pos));
continue;
}
},
Some(XamlValue::Str(v)) => match v.trim().parse::<f64>() {
Ok(d) => PfKeyValue::Double(d),
Err(_) => {
warnings.push(format!("{}: bad keyframe value `{v}`", frame.pos));
continue;
}
},
_ => {
warnings.push(format!("{}: keyframe needs Value=", frame.pos));
continue;
}
};
frames.push(PfKeyFrame {
time,
value,
interp,
});
}
frames
}
fn normalize_target_property(path: &str) -> String {
let path = path.trim();
let last_seg = |seg: &str| -> String {
seg.rsplit('.')
.next()
.unwrap_or_default()
.trim()
.to_string()
};
let mut groups: Vec<&str> = Vec::new();
let mut rest = path;
while let Some(open) = rest.find('(') {
let Some(close) = rest[open..].find(')') else {
break;
};
groups.push(&rest[open + 1..open + close]);
rest = &rest[open + close + 1..];
}
if groups.len() >= 2 {
let final_group = groups.last().expect("len >= 2");
let owner = final_group.split('.').next().unwrap_or_default().trim();
if owner.ends_with("Transform") {
return last_seg(final_group);
}
return last_seg(groups[0]);
}
if let Some(only) = groups.first() {
return last_seg(only);
}
let segments: Vec<&str> = path.split('.').collect();
if segments.len() > 1 && matches!(segments[segments.len() - 1].trim(), "Color" | "Offset") {
return segments[segments.len() - 2].trim().to_string();
}
last_seg(path)
}
pub(crate) fn parse_visual_state_groups(
pe: &bevy_pf_xaml::XamlPropertyElement,
scopes: &ResourceScopes,
local: &ResourceDictionary,
warnings: &mut Vec<String>,
) -> Result<(Vec<crate::animation::PfVisualStateGroup>, Vec<PfTrigger>), PfError> {
let mut state_groups = Vec::new();
let mut triggers = Vec::new();
{
for group in pe.elements() {
if group.name != "VisualStateGroup" {
continue;
}
let name = group
.x_name
.clone()
.unwrap_or_else(|| "CommonStates".to_string());
let mut states = Vec::new();
let mut transitions = Vec::new();
if let Some(tpe) = group
.property_elements
.iter()
.find(|p| p.name == "Transitions")
{
for t in tpe.elements() {
if t.name == "VisualTransition" {
transitions.push(parse_visual_transition(t, warnings));
}
}
}
for state in group.child_elements() {
match state.name.as_str() {
"VisualState" => {
let storyboard = state
.child_elements()
.find(|c| c.name == "Storyboard")
.map(|sb| parse_storyboard(sb, warnings))
.transpose()?
.map(Arc::new);
let state_name = state.x_name.clone().unwrap_or_default();
let mut state_setters = Vec::new();
if let Some(spe) = state
.property_elements
.iter()
.find(|pe| pe.name == "Setters")
{
for sn in spe.elements() {
if sn.name != "Setter" {
continue;
}
match parse_setter(sn, scopes, local, warnings) {
Ok(setter) => state_setters.push(setter),
Err(e) => {
warnings.push(format!("VisualState `{state_name}`: {e}"))
}
}
}
}
if !state_setters.is_empty() {
triggers.push(crate::resources::PfTrigger {
conditions: vec![PfTriggerCondition::VisualState {
group: name.clone(),
state: state_name.clone(),
}],
setters: state_setters,
enter_storyboards: Vec::new(),
exit_storyboards: Vec::new(),
});
}
states.push(crate::animation::PfVisualState {
name: state_name,
storyboard,
});
}
"VisualStateGroup.Transitions" => {
for t in state.child_elements() {
if t.name == "VisualTransition" {
transitions.push(parse_visual_transition(t, warnings));
}
}
}
"VisualTransition" => {
transitions.push(parse_visual_transition(state, warnings));
}
_ => {}
}
}
state_groups.push(crate::animation::PfVisualStateGroup {
name,
states,
transitions,
});
}
}
Ok((state_groups, triggers))
}
fn parse_visual_transition(
node: &XamlNode,
warnings: &mut Vec<String>,
) -> crate::animation::PfVisualTransition {
let attr = |name: &str| match node.attribute(name) {
Some(XamlValue::Str(v)) => Some(v.clone()),
_ => None,
};
let easing = node
.property_element("GeneratedEasingFunction")
.and_then(|pe| pe.elements().next())
.map(|f| parse_easing_element(f, warnings));
crate::animation::PfVisualTransition {
from: attr("From"),
to: attr("To"),
duration: attr("GeneratedDuration")
.and_then(|d| crate::animation::parse_duration(&d))
.unwrap_or(0.0),
easing,
}
}
fn parse_easing_element(f: &XamlNode, warnings: &mut Vec<String>) -> crate::animation::PfEasing {
use crate::animation::{EasingMode, PfEasing};
let mode = match f.attribute("EasingMode") {
Some(XamlValue::Str(m)) if m.eq_ignore_ascii_case("easein") => EasingMode::In,
Some(XamlValue::Str(m)) if m.eq_ignore_ascii_case("easeinout") => EasingMode::InOut,
_ => EasingMode::Out, };
match f.name.as_str() {
"QuadraticEase" => PfEasing::Quadratic(mode),
"CubicEase" => PfEasing::Cubic(mode),
"QuarticEase" => PfEasing::Quartic(mode),
"QuinticEase" => PfEasing::Quintic(mode),
"SineEase" => PfEasing::Sine(mode),
"CircleEase" => PfEasing::Circle(mode),
"BackEase" => PfEasing::Back(mode),
"ElasticEase" => PfEasing::Elastic(mode),
"BounceEase" => PfEasing::Bounce(mode),
"ExponentialEase" => PfEasing::Exponential(mode),
"PowerEase" => PfEasing::Quadratic(mode),
other => {
warnings.push(format!(
"{}: easing `{other}` is not supported; Linear used",
f.pos
));
PfEasing::Linear
}
}
}
pub(crate) fn parse_storyboard(
node: &XamlNode,
warnings: &mut Vec<String>,
) -> Result<crate::animation::PfStoryboard, PfError> {
use crate::animation::{PfAnimKind, PfAnimationSpec, PfFill, PfRepeat, parse_duration};
let mut children = Vec::new();
for child in node.child_elements() {
let attr = |name: &str| -> Option<String> {
match child.attribute(name) {
Some(XamlValue::Str(s)) => Some(s.clone()),
_ => None,
}
};
let attached = |name: &str| -> Option<String> {
match child.attached_attribute("Storyboard", name) {
Some(XamlValue::Str(s)) => Some(s.clone()),
_ => None,
}
};
let kind = match child.name.as_str() {
"DoubleAnimation" => {
let to = attr("To").and_then(|t| t.trim().parse::<f64>().ok());
let by = attr("By").and_then(|t| t.trim().parse::<f64>().ok());
if to.is_none() && by.is_none() {
warnings.push(format!(
"{}: DoubleAnimation needs To= or By=; skipped",
child.pos
));
continue;
}
PfAnimKind::Double {
from: attr("From").and_then(|f| f.trim().parse().ok()),
to,
by,
}
}
"ColorAnimation" => {
let Some(to) = attr("To").and_then(|t| t.parse::<v::PfColor>().ok()) else {
warnings.push(format!("{}: ColorAnimation needs To=; skipped", child.pos));
continue;
};
PfAnimKind::Color {
from: attr("From").and_then(|f| f.parse().ok()),
to,
}
}
"DoubleAnimationUsingKeyFrames" => PfAnimKind::DoubleKeyFrames {
frames: parse_key_frames(child, false, warnings),
},
"ColorAnimationUsingKeyFrames" => PfAnimKind::ColorKeyFrames {
frames: parse_key_frames(child, true, warnings),
},
other => {
warnings.push(format!(
"{}: animation `{other}` is not supported yet; skipped",
child.pos
));
continue;
}
};
let Some(target_property) = attached("TargetProperty") else {
warnings.push(format!(
"{}: animation needs Storyboard.TargetProperty; skipped",
child.pos
));
continue;
};
let target_property = normalize_target_property(&target_property);
let duration = attr("Duration")
.and_then(|d| parse_duration(&d))
.unwrap_or_else(|| match &kind {
PfAnimKind::DoubleKeyFrames { frames } | PfAnimKind::ColorKeyFrames { frames } => {
frames
.iter()
.filter_map(|f| f.time)
.fold(0.0_f32, f32::max)
.max(1.0 / 240.0)
}
_ => 1.0,
});
let begin_time = attr("BeginTime")
.and_then(|d| parse_duration(&d))
.unwrap_or(0.0);
let repeat = match attr("RepeatBehavior").as_deref() {
None => PfRepeat::Once,
Some(r) if r.eq_ignore_ascii_case("forever") => PfRepeat::Forever,
Some(r) => match r.trim().trim_end_matches(['x', 'X']).parse::<f32>() {
Ok(n) => PfRepeat::Count(n),
Err(_) => {
warnings.push(format!(
"{}: bad RepeatBehavior `{r}`; using Once",
child.pos
));
PfRepeat::Once
}
},
};
let fill = match attr("FillBehavior").as_deref() {
Some(f) if f.eq_ignore_ascii_case("stop") => PfFill::Stop,
_ => PfFill::HoldEnd,
};
let easing = if let Some(pe) = child.property_element("EasingFunction") {
match pe.single_element() {
Some(f) => parse_easing_element(f, warnings),
None => crate::animation::PfEasing::Linear,
}
} else {
let accel = attr("AccelerationRatio")
.and_then(|a| a.trim().parse::<f32>().ok())
.unwrap_or(0.0);
let decel = attr("DecelerationRatio")
.and_then(|d| d.trim().parse::<f32>().ok())
.unwrap_or(0.0);
if accel > 0.0 || decel > 0.0 {
crate::animation::PfEasing::AccelDecel { accel, decel }
} else {
crate::animation::PfEasing::Linear
}
};
children.push(PfAnimationSpec {
easing,
target_name: attached("TargetName"),
target_property,
kind,
duration,
begin_time,
repeat,
auto_reverse: attr("AutoReverse")
.map(|a| a.eq_ignore_ascii_case("true"))
.unwrap_or(false),
fill,
speed_ratio: attr("SpeedRatio")
.and_then(|s| s.trim().parse::<f32>().ok())
.filter(|r| *r > 0.0)
.unwrap_or(1.0),
});
}
Ok(crate::animation::PfStoryboard { children })
}
pub(crate) fn parse_event_trigger(
node: &XamlNode,
scopes: &ResourceScopes,
local: &ResourceDictionary,
warnings: &mut Vec<String>,
) -> Option<crate::animation::PfEventTrigger> {
let event = match node.attribute("RoutedEvent") {
Some(XamlValue::Str(e)) => e.rsplit('.').next().unwrap_or(e).to_string(),
_ => {
warnings.push(format!("{}: EventTrigger needs RoutedEvent", node.pos));
return None;
}
};
if !matches!(
event.as_str(),
"Loaded" | "MouseEnter" | "MouseLeave" | "Click"
) {
warnings.push(format!(
"{}: EventTrigger RoutedEvent `{event}` is not supported yet",
node.pos
));
return None;
}
let begin = node
.child_elements()
.find(|c| c.name == "BeginStoryboard")?;
let storyboard = if let Some(sb) = begin.child_elements().find(|c| c.name == "Storyboard") {
match parse_storyboard(sb, warnings) {
Ok(sb) => std::sync::Arc::new(sb),
Err(e) => {
warnings.push(format!("{}: {e}", node.pos));
return None;
}
}
} else if let Some(XamlValue::Extension(ext)) = begin.attribute("Storyboard") {
match static_resource_key(ext)
.ok()
.and_then(|key| local.get(&key).or_else(|| scopes.lookup(&key)))
{
Some(PfValue::Storyboard(sb)) => sb.clone(),
_ => {
warnings.push(format!(
"{}: BeginStoryboard Storyboard resource not found",
begin.pos
));
return None;
}
}
} else {
warnings.push(format!(
"{}: BeginStoryboard needs an inline Storyboard or a resource",
begin.pos
));
return None;
};
Some(crate::animation::PfEventTrigger { event, storyboard })
}
fn parse_trigger(
node: &XamlNode,
scopes: &ResourceScopes,
local: &ResourceDictionary,
warnings: &mut Vec<String>,
allow_target_name: bool,
) -> Result<Option<PfTrigger>, PfError> {
let attr_str = |el: &XamlNode, name: &str| -> Option<String> {
match el.attribute(name) {
Some(XamlValue::Str(s)) => Some(s.clone()),
_ => None,
}
};
let binding_path = |el: &XamlNode| -> Option<String> {
match el.attribute("Binding") {
Some(XamlValue::Extension(ext)) if ext.name == "Binding" => {
let spec = crate::binding::parse_binding_extension(ext);
(spec.unsupported.is_none() && spec.element_name.is_none()).then_some(spec.path)
}
_ => None,
}
};
let trigger_value = |el: &XamlNode| -> Option<PfTriggerValue> {
match el.attribute("Value") {
Some(XamlValue::Str(v)) => return Some(PfTriggerValue::Text(v.clone())),
Some(XamlValue::Extension(ext)) if ext.name == "x:Null" || ext.name == "Null" => {
return Some(PfTriggerValue::Null);
}
_ => {}
}
let pe = el.property_element("Value")?;
if let Some(inner) = pe.single_element() {
return inner.text_content().map(PfTriggerValue::Text);
}
pe.values
.iter()
.find_map(|c| c.as_text())
.map(|t| PfTriggerValue::Text(t.to_string()))
};
let mut conditions = Vec::new();
match node.name.as_str() {
"Trigger" => {
let property = attr_str(node, "Property")
.ok_or_else(|| PfError::resource("Trigger needs Property"))?;
let value =
trigger_value(node).ok_or_else(|| PfError::resource("Trigger needs Value"))?;
conditions.push(PfTriggerCondition::Property { property, value });
}
"DataTrigger" => {
let path = binding_path(node)
.ok_or_else(|| PfError::resource("DataTrigger needs a plain-path Binding"))?;
let value =
trigger_value(node).ok_or_else(|| PfError::resource("DataTrigger needs Value"))?;
conditions.push(PfTriggerCondition::Data { path, value });
}
"MultiTrigger" | "MultiDataTrigger" => {
let Some(conds) = node.property_element("Conditions") else {
return Err(PfError::resource("MultiTrigger needs Conditions"));
};
for cond in conds.elements() {
if cond.name != "Condition" {
continue;
}
let value = trigger_value(cond)
.ok_or_else(|| PfError::resource("Condition needs Value"))?;
if let Some(property) = attr_str(cond, "Property") {
conditions.push(PfTriggerCondition::Property { property, value });
} else if let Some(path) = binding_path(cond) {
conditions.push(PfTriggerCondition::Data { path, value });
} else {
return Err(PfError::resource(
"Condition needs Property or a plain-path Binding",
));
}
}
if conditions.is_empty() {
return Err(PfError::resource("MultiTrigger has no conditions"));
}
}
"EventTrigger" => {
warnings.push(format!(
"{}: EventTrigger (storyboards) is not supported yet",
node.pos
));
return Ok(None);
}
other => {
warnings.push(format!(
"{}: trigger `{other}` is not supported yet",
node.pos
));
return Ok(None);
}
}
let parse_actions =
|pe_name: &str, warnings: &mut Vec<String>| -> Vec<Arc<crate::animation::PfStoryboard>> {
let Some(pe) = node.property_element(pe_name) else {
return Vec::new();
};
let mut out = Vec::new();
for action in pe.elements() {
if action.name != "BeginStoryboard" {
warnings.push(format!(
"{}: trigger action `{}` is not supported yet",
action.pos, action.name
));
continue;
}
if let Some(sb) = action.child_elements().find(|c| c.name == "Storyboard") {
match parse_storyboard(sb, warnings) {
Ok(sb) => out.push(Arc::new(sb)),
Err(e) => warnings.push(format!("{}: {e}", action.pos)),
}
} else if let Some(XamlValue::Extension(ext)) = action.attribute("Storyboard") {
match static_resource_key(ext).ok().and_then(|key| {
local
.get(&key)
.cloned()
.or_else(|| scopes.lookup(&key).cloned())
}) {
Some(PfValue::Storyboard(sb)) => out.push(sb),
_ => warnings.push(format!(
"{}: BeginStoryboard Storyboard resource not found",
action.pos
)),
}
}
}
out
};
let enter_storyboards = parse_actions("EnterActions", warnings);
let exit_storyboards = parse_actions("ExitActions", warnings);
let from_property = node
.property_element("Setters")
.map(|p| p.elements().collect::<Vec<_>>());
let setter_elems: Vec<&XamlNode> = match &from_property {
Some(elems) => elems.clone(),
None => node.child_elements().collect(),
};
let mut setters = Vec::new();
for setter in setter_elems {
if setter.name != "Setter" {
continue;
}
if !allow_target_name && setter.attribute("TargetName").is_some() {
warnings.push(format!(
"{}: trigger setter TargetName is only valid in ControlTemplate triggers; skipped",
setter.pos
));
continue;
}
match parse_setter(setter, scopes, local, warnings) {
Ok(s) => setters.push(s),
Err(e) => warnings.push(format!("{}: skipped trigger setter: {e}", setter.pos)),
}
}
Ok(Some(PfTrigger {
conditions,
setters,
enter_storyboards,
exit_storyboards,
}))
}
fn parse_setter(
node: &XamlNode,
scopes: &ResourceScopes,
local: &ResourceDictionary,
warnings: &mut Vec<String>,
) -> Result<PfSetter, PfError> {
let prop = match node.attribute("Property") {
Some(XamlValue::Str(s)) => s.clone(),
_ => return Err(PfError::resource("Setter needs Property")),
};
let (owner, property) = match prop.split_once('.') {
Some((o @ ("Control" | "FrameworkElement" | "UIElement" | "TextElement"), p)) => {
let _ = o;
(None, p.to_string())
}
Some((o, p)) => (Some(o.to_string()), p.to_string()),
None => (None, prop),
};
let value = if let Some(value) = node.attribute("Value") {
match value {
XamlValue::Str(s) => PfSetterValue::Literal(s.clone()),
XamlValue::Extension(ext) if ext.name == "StaticResource" => {
PfSetterValue::Resource(static_resource_key(ext)?)
}
XamlValue::Extension(ext) if ext.name == "DynamicResource" => {
PfSetterValue::DynamicResource(static_resource_key(ext)?)
}
XamlValue::Extension(ext) if ext.name == "x:Null" || ext.name == "Null" => {
PfSetterValue::Null
}
XamlValue::Extension(ext) if ext.name == "AppThemeBinding" => {
PfSetterValue::AppTheme(parse_app_theme_arms(ext)?)
}
XamlValue::Extension(ext) if ext.name == "OnPlatform" || ext.name == "OnIdiom" => {
let kind = if ext.name == "OnPlatform" {
crate::device::SelectorKind::Platform
} else {
crate::device::SelectorKind::Idiom
};
PfSetterValue::Selector(kind, parse_selector_arms(ext, kind)?)
}
XamlValue::Extension(ext) if ext.name == "TemplateBinding" => {
let src = ext
.first_positional_str()
.ok_or_else(|| PfError::resource("TemplateBinding needs a source property"))?;
PfSetterValue::TemplatedParent(src.to_string())
}
XamlValue::Extension(ext) if ext.name == "Binding" => {
let spec = crate::binding::parse_binding_extension(ext);
match spec.relative {
Some(crate::binding::PfRelativeSource::TemplatedParent)
if !spec.path.is_empty() =>
{
PfSetterValue::TemplatedParent(spec.path)
}
Some(crate::binding::PfRelativeSource::TemplatedParent) => {
return Err(PfError::resource(
"a TemplatedParent setter binding needs a source property",
));
}
_ => {
return Err(PfError::resource(
"only `{Binding Prop, RelativeSource={RelativeSource TemplatedParent}}` \
is supported as a setter value",
));
}
}
}
XamlValue::Extension(ext) => {
return Err(PfError::resource(format!(
"setter value extension `{}` not supported yet",
ext.name
)));
}
}
} else if let Some(pe) = node.property_element("Value") {
let el = pe
.single_element()
.ok_or_else(|| PfError::resource("Setter.Value must hold one element"))?;
match parse_resource_value(el, scopes, local, warnings)? {
Some(v) => PfSetterValue::Value(v),
None => {
return Err(PfError::resource(format!(
"unsupported Setter.Value element `{}`",
el.name
)));
}
}
} else {
return Err(PfError::resource("Setter needs Value"));
};
Ok(PfSetter {
owner,
property,
value,
target_name: match node.attribute("TargetName") {
Some(XamlValue::Str(t)) => Some(t.clone()),
_ => None,
},
})
}
#[cfg(test)]
mod target_property_tests {
use super::normalize_target_property;
#[test]
fn plain_property() {
assert_eq!(normalize_target_property("Opacity"), "Opacity");
assert_eq!(normalize_target_property(" Width "), "Width");
}
#[test]
fn single_parenthesized_step() {
assert_eq!(
normalize_target_property("(Panel.Background)"),
"Background"
);
}
#[test]
fn brush_color_chain_resolves_to_the_brush_property() {
assert_eq!(
normalize_target_property("(Border.Background).(SolidColorBrush.Color)"),
"Background"
);
assert_eq!(
normalize_target_property("(Control.Foreground).(SolidColorBrush.Color)"),
"Foreground"
);
}
#[test]
fn unparenthesized_value_accessor() {
assert_eq!(normalize_target_property("Background.Color"), "Background");
}
#[test]
fn owner_qualified_property_named_like_an_accessor() {
assert_eq!(normalize_target_property("(UIElement.Opacity)"), "Opacity");
assert_eq!(normalize_target_property("(GradientStop.Color)"), "Color");
}
#[test]
fn transform_chain_resolves_to_the_field() {
assert_eq!(
normalize_target_property("(FrameworkElement.LayoutTransform).(RotateTransform.Angle)"),
"Angle"
);
assert_eq!(
normalize_target_property(
"(UIElement.RenderTransform).(TransformGroup.Children)[0].(ScaleTransform.ScaleX)"
),
"ScaleX"
);
assert_eq!(
normalize_target_property("(UIElement.RenderTransform).(TranslateTransform.X)"),
"X"
);
}
#[test]
fn attached_style_owner_property_keeps_last_segment() {
assert_eq!(
normalize_target_property("Border.BorderThickness"),
"BorderThickness"
);
assert_eq!(normalize_target_property("(Grid.Row)"), "Row");
}
}
#[cfg(test)]
mod storyboard_parse_tests {
use super::parse_storyboard;
use crate::animation::PfAnimKind;
fn storyboard(inner: &str) -> crate::animation::PfStoryboard {
let doc = bevy_pf_xaml::parse(&format!("<Storyboard>{inner}</Storyboard>"))
.expect("test XAML must parse");
let mut warnings = Vec::new();
parse_storyboard(&doc.root, &mut warnings).expect("storyboard must parse")
}
#[test]
fn by_is_accepted_without_to() {
let sb = storyboard(
r#"<DoubleAnimation Storyboard.TargetProperty="Opacity" By="0.25" Duration="0:0:1"/>"#,
);
assert_eq!(sb.children.len(), 1, "By= must no longer be skipped");
match &sb.children[0].kind {
PfAnimKind::Double { to, by, .. } => {
assert_eq!(*by, Some(0.25));
assert_eq!(*to, None);
}
other => panic!("expected Double, got {other:?}"),
}
}
#[test]
fn to_and_by_both_parsed_so_to_can_win() {
let sb = storyboard(
r#"<DoubleAnimation Storyboard.TargetProperty="Opacity" From="0" To="1" By="5"/>"#,
);
match &sb.children[0].kind {
PfAnimKind::Double { from, to, by } => {
assert_eq!(*from, Some(0.0));
assert_eq!(*to, Some(1.0));
assert_eq!(*by, Some(5.0));
}
other => panic!("expected Double, got {other:?}"),
}
}
#[test]
fn neither_to_nor_by_is_still_rejected() {
let sb = storyboard(r#"<DoubleAnimation Storyboard.TargetProperty="Opacity"/>"#);
assert!(sb.children.is_empty());
}
#[test]
fn speed_ratio_defaults_to_one_and_rejects_nonpositive() {
let sb = storyboard(
r#"<DoubleAnimation Storyboard.TargetProperty="Opacity" To="1"/>
<DoubleAnimation Storyboard.TargetProperty="Opacity" To="1" SpeedRatio="2.5"/>
<DoubleAnimation Storyboard.TargetProperty="Opacity" To="1" SpeedRatio="0"/>"#,
);
assert_eq!(sb.children[0].speed_ratio, 1.0);
assert_eq!(sb.children[1].speed_ratio, 2.5);
assert_eq!(sb.children[2].speed_ratio, 1.0);
}
#[test]
fn color_animation_with_canonical_brush_path() {
let sb = storyboard(
r##"<ColorAnimation To="#FF00FF"
Storyboard.TargetProperty="(Border.Background).(SolidColorBrush.Color)"/>"##,
);
assert_eq!(sb.children.len(), 1);
assert_eq!(sb.children[0].target_property, "Background");
}
}