use crate::animations::build_ui_transform;
use bevy::picking::Pickable;
use bevy::platform::collections::HashMap;
use bevy::prelude::*;
use bevy::sprite::{BorderRect, SliceScaleMode, TextureSlicer};
use bevy::text::{FontSize as BevyFontSize, LetterSpacing, LineHeight};
use bevy::ui::FocusPolicy;
use bevy::ui::widget::NodeImageMode;
use crate::cursor::NodeCursor;
use crate::plugin::Fonts;
use crate::protocol::{
Angle, AngularStop, AtlasSpec, BoxShadowList, BoxShadowSpec, ConicGradientSpec, FontSize,
GradientList, GradientSpec, GradientStop, ImageMode, ImageModeSpec, Length, LetterSpacingSpec,
LineHeightSpec, LinearGradientSpec, Props, RadialGradientSpec, RadialShapeSpec, Rect,
SliceBorder, SliceScale, SliceSpec, Style, StyleDirty,
};
pub fn parse_color(input: &str) -> Color {
match crate::canvas::parse_css_color(input) {
Some(c) => Color::from(c),
None => {
warn!("unrecognized color {input:?}; using magenta debug fallback");
Color::srgb(1.0, 0.0, 1.0)
}
}
}
pub fn apply_opacity(color: Color, opacity: Option<f32>) -> Color {
match opacity {
Some(o) => color.with_alpha(color.alpha() * o),
None => color,
}
}
pub fn length_to_val(length: Length) -> Val {
match length {
Length::Auto => Val::Auto,
Length::Px(v) => Val::Px(v),
Length::Percent(v) => Val::Percent(v),
Length::Vw(v) => Val::Vw(v),
Length::Vh(v) => Val::Vh(v),
Length::VMin(v) => Val::VMin(v),
Length::VMax(v) => Val::VMax(v),
}
}
fn font_size_to_bevy(size: FontSize) -> BevyFontSize {
match size {
FontSize::Px(v) => BevyFontSize::Px(v),
FontSize::Vw(v) => BevyFontSize::Vw(v),
FontSize::Vh(v) => BevyFontSize::Vh(v),
FontSize::VMin(v) => BevyFontSize::VMin(v),
FontSize::VMax(v) => BevyFontSize::VMax(v),
FontSize::Rem(v) => BevyFontSize::Rem(v),
}
}
pub fn rect_to_uirect(rect: Rect) -> UiRect {
UiRect {
left: length_to_val(rect.left),
right: length_to_val(rect.right),
top: length_to_val(rect.top),
bottom: length_to_val(rect.bottom),
}
}
pub fn rect_to_border_radius(rect: Rect) -> BorderRadius {
BorderRadius {
top_left: length_to_val(rect.top),
top_right: length_to_val(rect.right),
bottom_right: length_to_val(rect.bottom),
bottom_left: length_to_val(rect.left),
}
}
fn parse_color_space(s: Option<&str>) -> InterpolationColorSpace {
match s {
Some("oklch") => InterpolationColorSpace::Oklcha,
Some("oklchLong") => InterpolationColorSpace::OklchaLong,
Some("srgb") => InterpolationColorSpace::Srgba,
Some("linearRgb") => InterpolationColorSpace::LinearRgba,
Some("hsl") => InterpolationColorSpace::Hsla,
Some("hslLong") => InterpolationColorSpace::HslaLong,
Some("hsv") => InterpolationColorSpace::Hsva,
Some("hsvLong") => InterpolationColorSpace::HsvaLong,
_ => InterpolationColorSpace::Oklaba,
}
}
fn parse_position(s: Option<&str>) -> UiPosition {
match s {
Some("top") => UiPosition::TOP,
Some("bottom") => UiPosition::BOTTOM,
Some("left") => UiPosition::LEFT,
Some("right") => UiPosition::RIGHT,
Some("topLeft") => UiPosition::TOP_LEFT,
Some("topRight") => UiPosition::TOP_RIGHT,
Some("bottomLeft") => UiPosition::BOTTOM_LEFT,
Some("bottomRight") => UiPosition::BOTTOM_RIGHT,
_ => UiPosition::CENTER,
}
}
fn parse_radial_shape(shape: Option<&RadialShapeSpec>) -> RadialGradientShape {
match shape {
Some(RadialShapeSpec::Circle { circle }) => {
RadialGradientShape::Circle(length_to_val(*circle))
}
Some(RadialShapeSpec::Ellipse { ellipse }) => {
RadialGradientShape::Ellipse(length_to_val(ellipse[0]), length_to_val(ellipse[1]))
}
Some(RadialShapeSpec::Keyword(k)) => match k.as_str() {
"closestSide" => RadialGradientShape::ClosestSide,
"farthestSide" => RadialGradientShape::FarthestSide,
"farthestCorner" => RadialGradientShape::FarthestCorner,
_ => RadialGradientShape::ClosestCorner,
},
None => RadialGradientShape::ClosestCorner,
}
}
fn color_stop(stop: &GradientStop, opacity: Option<f32>) -> ColorStop {
ColorStop {
color: apply_opacity(parse_color(&stop.color), opacity),
point: stop.position.map(length_to_val).unwrap_or(Val::Auto),
hint: stop.hint.unwrap_or(0.5),
}
}
fn angular_stop(stop: &AngularStop, opacity: Option<f32>) -> AngularColorStop {
AngularColorStop {
color: apply_opacity(parse_color(&stop.color), opacity),
angle: stop.angle.map(Angle::radians),
hint: stop.hint.unwrap_or(0.5),
}
}
fn build_linear(spec: &LinearGradientSpec, opacity: Option<f32>) -> Gradient {
LinearGradient::new(
spec.angle.map(Angle::radians).unwrap_or(0.0),
spec.stops.iter().map(|s| color_stop(s, opacity)).collect(),
)
.in_color_space(parse_color_space(spec.color_space.as_deref()))
.into()
}
fn build_radial(spec: &RadialGradientSpec, opacity: Option<f32>) -> Gradient {
RadialGradient::new(
parse_position(spec.position.as_deref()),
parse_radial_shape(spec.shape.as_ref()),
spec.stops.iter().map(|s| color_stop(s, opacity)).collect(),
)
.in_color_space(parse_color_space(spec.color_space.as_deref()))
.into()
}
fn build_conic(spec: &ConicGradientSpec, opacity: Option<f32>) -> Gradient {
ConicGradient::new(
parse_position(spec.position.as_deref()),
spec.stops
.iter()
.map(|s| angular_stop(s, opacity))
.collect(),
)
.with_start(spec.start.map(Angle::radians).unwrap_or(0.0))
.in_color_space(parse_color_space(spec.color_space.as_deref()))
.into()
}
fn build_gradient(spec: &GradientSpec, opacity: Option<f32>) -> Gradient {
match spec {
GradientSpec::Linear(l) => build_linear(l, opacity),
GradientSpec::Radial(r) => build_radial(r, opacity),
GradientSpec::Conic(c) => build_conic(c, opacity),
}
}
pub fn build_gradients(list: &GradientList, opacity: Option<f32>) -> Vec<Gradient> {
match list {
GradientList::One(g) => vec![build_gradient(g, opacity)],
GradientList::Many(gs) => gs.iter().map(|g| build_gradient(g, opacity)).collect(),
}
}
fn shadow_style(b: &BoxShadowSpec) -> ShadowStyle {
ShadowStyle {
color: b.color.as_deref().map(parse_color).unwrap_or(Color::BLACK),
x_offset: b.x_offset.map(length_to_val).unwrap_or(Val::Px(0.0)),
y_offset: b.y_offset.map(length_to_val).unwrap_or(Val::Px(0.0)),
spread_radius: b.spread_radius.map(length_to_val).unwrap_or(Val::Px(0.0)),
blur_radius: b.blur_radius.map(length_to_val).unwrap_or(Val::Px(0.0)),
}
}
pub fn build_box_shadows(list: &BoxShadowList) -> Vec<ShadowStyle> {
match list {
BoxShadowList::One(b) => vec![shadow_style(b)],
BoxShadowList::Many(bs) => bs.iter().map(shadow_style).collect(),
}
}
pub fn node_from_style(style: &Option<Style>) -> Node {
let mut node = Node::default();
let Some(s) = style.as_ref() else {
return node;
};
if let Some(v) = s.display {
node.display = v;
}
if let Some(v) = s.box_sizing {
node.box_sizing = v;
}
if let Some(v) = s.position_type {
node.position_type = v;
}
if let Some(v) = s.overflow_x {
node.overflow.x = v;
}
if let Some(v) = s.overflow_y {
node.overflow.y = v;
}
if let Some(v) = s.scrollbar_width {
node.scrollbar_width = v;
}
if let Some(v) = s.left {
node.left = length_to_val(v);
}
if let Some(v) = s.right {
node.right = length_to_val(v);
}
if let Some(v) = s.top {
node.top = length_to_val(v);
}
if let Some(v) = s.bottom {
node.bottom = length_to_val(v);
}
if let Some(v) = s.width {
node.width = length_to_val(v);
}
if let Some(v) = s.height {
node.height = length_to_val(v);
}
if let Some(v) = s.min_width {
node.min_width = length_to_val(v);
}
if let Some(v) = s.min_height {
node.min_height = length_to_val(v);
}
if let Some(v) = s.max_width {
node.max_width = length_to_val(v);
}
if let Some(v) = s.max_height {
node.max_height = length_to_val(v);
}
if let Some(v) = s.aspect_ratio {
node.aspect_ratio = Some(v);
}
if let Some(v) = s.align_items {
node.align_items = v;
}
if let Some(v) = s.justify_items {
node.justify_items = v;
}
if let Some(v) = s.align_self {
node.align_self = v;
}
if let Some(v) = s.justify_self {
node.justify_self = v;
}
if let Some(v) = s.align_content {
node.align_content = v;
}
if let Some(v) = s.justify_content {
node.justify_content = v;
}
if let Some(r) = s.margin {
node.margin = rect_to_uirect(r);
}
if let Some(r) = s.padding {
node.padding = rect_to_uirect(r);
}
if let Some(r) = s.border {
node.border = rect_to_uirect(r);
}
if let Some(v) = s.flex_direction {
node.flex_direction = v;
}
if let Some(v) = s.flex_wrap {
node.flex_wrap = v;
}
if let Some(v) = s.flex_grow {
node.flex_grow = v;
}
if let Some(v) = s.flex_shrink {
node.flex_shrink = v;
}
if let Some(v) = s.flex_basis {
node.flex_basis = length_to_val(v);
}
if let Some(v) = s.gap {
node.row_gap = length_to_val(v);
node.column_gap = length_to_val(v);
}
if let Some(v) = s.row_gap {
node.row_gap = length_to_val(v);
}
if let Some(v) = s.column_gap {
node.column_gap = length_to_val(v);
}
if let Some(v) = s.grid_auto_flow {
node.grid_auto_flow = v;
}
if let Some(v) = &s.grid_template_rows {
node.grid_template_rows = v.clone();
}
if let Some(v) = &s.grid_template_columns {
node.grid_template_columns = v.clone();
}
if let Some(v) = &s.grid_auto_rows {
node.grid_auto_rows = v.clone();
}
if let Some(v) = &s.grid_auto_columns {
node.grid_auto_columns = v.clone();
}
if let Some(v) = s.grid_row {
node.grid_row = v;
}
if let Some(v) = s.grid_column {
node.grid_column = v;
}
if let Some(r) = s.border_radius {
node.border_radius = rect_to_border_radius(r);
}
node
}
fn set_node_if_changed(node: Node) -> impl EntityCommand {
move |mut entity: EntityWorldMut| match entity.get_mut::<Node>() {
Some(mut current) => {
current.set_if_neq(node);
}
None => {
entity.insert(node);
}
}
}
pub fn apply_style(ec: &mut EntityCommands, style: &Option<Style>) {
apply_style_masked(ec, style, StyleDirty::ALL);
}
pub fn apply_style_masked(ec: &mut EntityCommands, style: &Option<Style>, dirty: StyleDirty) {
use crate::protocol::style_groups as g;
if dirty.intersects(g::LAYOUT) {
ec.queue(set_node_if_changed(node_from_style(style)));
}
let s = style.as_ref();
let opacity = s.and_then(|s| s.opacity);
if dirty.intersects(g::BACKGROUND) {
let has_filter = s.map(|s| s.filter.is_some()).unwrap_or(false);
match s.and_then(|s| s.background_color.as_deref()) {
Some(hex) if !has_filter => {
ec.insert(BackgroundColor(apply_opacity(parse_color(hex), opacity)));
}
_ => {
ec.remove::<BackgroundColor>();
}
}
}
if dirty.intersects(g::TRANSFORM)
&& let Some(t) = s.and_then(|s| s.transform)
{
ec.insert(build_ui_transform(
t.translate_x.map(length_to_val),
t.translate_y.map(length_to_val),
t.scale,
t.scale_x,
t.scale_y,
t.rotate.map(Angle::radians),
));
}
if dirty.intersects(g::BORDER_COLOR) {
match s.and_then(|s| s.border_color.as_ref()) {
Some(spec) => {
let side =
|c: &Option<String>| c.as_deref().map(parse_color).unwrap_or(Color::NONE);
ec.insert(BorderColor {
top: side(&spec.top),
right: side(&spec.right),
bottom: side(&spec.bottom),
left: side(&spec.left),
});
}
None => {
ec.remove::<BorderColor>();
}
}
}
if dirty.intersects(g::OUTLINE) {
match s.and_then(|s| s.outline.as_ref()) {
Some(o) => {
ec.insert(Outline {
width: o.width.map(length_to_val).unwrap_or(Val::Px(1.0)),
offset: o.offset.map(length_to_val).unwrap_or(Val::Px(0.0)),
color: o.color.as_deref().map(parse_color).unwrap_or(Color::WHITE),
});
}
None => {
ec.remove::<Outline>();
}
}
}
if dirty.intersects(g::BOX_SHADOW) {
match s.and_then(|s| s.box_shadow.as_ref()) {
Some(b) => {
ec.insert(BoxShadow(build_box_shadows(b)));
}
None => {
ec.remove::<BoxShadow>();
}
}
}
if dirty.intersects(g::BG_GRADIENT) {
match s.and_then(|s| s.background_gradient.as_ref()) {
Some(grad) => {
ec.insert(BackgroundGradient(build_gradients(grad, opacity)));
}
None => {
ec.remove::<BackgroundGradient>();
}
}
}
if dirty.intersects(g::BORDER_GRADIENT) {
match s.and_then(|s| s.border_gradient.as_ref()) {
Some(grad) => {
ec.insert(BorderGradient(build_gradients(grad, opacity)));
}
None => {
ec.remove::<BorderGradient>();
}
}
}
if dirty.intersects(g::TEXT_SHADOW) {
match text_shadow(s) {
Some(shadow) => {
ec.insert(shadow);
}
None => {
ec.remove::<TextShadow>();
}
}
}
if dirty.intersects(g::Z_INDEX) {
match s.and_then(|s| s.z_index) {
Some(z) => {
ec.insert(ZIndex(z));
}
None => {
ec.remove::<ZIndex>();
}
}
}
if dirty.intersects(g::GLOBAL_Z_INDEX) {
match s.and_then(|s| s.global_z_index) {
Some(z) => {
ec.insert(GlobalZIndex(z));
}
None => {
ec.remove::<GlobalZIndex>();
}
}
}
if dirty.intersects(g::CURSOR) {
match s.and_then(|s| s.cursor.as_ref()) {
Some(c) => {
ec.insert(NodeCursor(c.clone()));
}
None => {
ec.remove::<NodeCursor>();
}
}
}
if dirty.intersects(g::FOCUS_POLICY) {
let focus_policy = s.and_then(|s| s.focus_policy).unwrap_or(FocusPolicy::Pass); ec.insert(focus_policy);
ec.insert(Pickable {
should_block_lower: focus_policy == FocusPolicy::Block,
is_hoverable: true,
});
}
if dirty.intersects(g::TRANSITION) {
crate::transition::apply_transition(ec, style);
}
}
pub fn overlay_style(base: &Option<Style>, overlay: &Option<Style>) -> Option<Style> {
let Some(overlay) = overlay else {
return base.clone();
};
let mut merged = base.clone().unwrap_or_default();
macro_rules! overlay_field {
($(($f:ident, $name:literal, $g:tt, $ov:ident),)*) => {
$( overlay_field!(@one $f, $ov); )*
};
(@one $f:ident, overlay) => {
if overlay.$f.is_some() {
merged.$f = overlay.$f.clone();
}
};
(@one $f:ident, no_overlay) => {};
}
crate::protocol::with_style_fields!(overlay_field);
Some(merged)
}
pub fn image_node(props: &Props, assets: &AssetServer) -> ImageNode {
let mut image = match &props.src {
Some(path) => ImageNode::new(assets.load(path)),
None => ImageNode::solid_color(
props
.tint
.as_deref()
.map(parse_color)
.unwrap_or(Color::WHITE),
),
};
if let Some(tint) = &props.tint {
image.color = parse_color(tint);
}
image.color = apply_opacity(image.color, props.style.as_ref().and_then(|s| s.opacity));
image.flip_x = props.flip_x;
image.flip_y = props.flip_y;
if let Some(mode) = &props.image_mode {
image.image_mode = match mode {
ImageMode::Keyword(s) if s == "stretch" => NodeImageMode::Stretch,
ImageMode::Keyword(_) => NodeImageMode::Auto,
ImageMode::Spec(ImageModeSpec::Sliced(s)) => NodeImageMode::Sliced(slicer(s)),
ImageMode::Spec(ImageModeSpec::Tiled(t)) => NodeImageMode::Tiled {
tile_x: t.tile_x,
tile_y: t.tile_y,
stretch_value: t.stretch_value.unwrap_or(1.0),
},
};
}
if let Some(r) = &props.source_rect {
image.rect = Some(bevy::math::Rect::new(
r.x,
r.y,
r.x + r.width,
r.y + r.height,
));
}
if let Some(vb) = &props.visual_box {
image.visual_box = match vb.as_str() {
"content" => VisualBox::ContentBox,
"border" => VisualBox::BorderBox,
_ => VisualBox::PaddingBox,
};
}
image
}
#[derive(Resource, Default)]
pub struct AtlasLayoutCache(HashMap<AtlasKey, Handle<TextureAtlasLayout>>);
#[derive(PartialEq, Eq, Hash)]
struct AtlasKey {
tile_width: u32,
tile_height: u32,
columns: u32,
rows: u32,
padding: Option<[u32; 2]>,
offset: Option<[u32; 2]>,
}
impl AtlasKey {
fn of(a: &AtlasSpec) -> Self {
AtlasKey {
tile_width: a.tile_width,
tile_height: a.tile_height,
columns: a.columns,
rows: a.rows,
padding: a.padding,
offset: a.offset,
}
}
}
pub fn apply_atlas(
image: &mut ImageNode,
props: &Props,
layouts: &mut Assets<TextureAtlasLayout>,
cache: &mut AtlasLayoutCache,
) {
let Some(a) = &props.atlas else { return };
let handle = cache
.0
.entry(AtlasKey::of(a))
.or_insert_with(|| {
layouts.add(TextureAtlasLayout::from_grid(
UVec2::new(a.tile_width, a.tile_height),
a.columns,
a.rows,
a.padding.map(|[x, y]| UVec2::new(x, y)),
a.offset.map(|[x, y]| UVec2::new(x, y)),
))
})
.clone();
image.texture_atlas = Some(TextureAtlas {
layout: handle,
index: a.index,
});
}
fn slicer(spec: &SliceSpec) -> TextureSlicer {
let border = match spec.border {
SliceBorder::Zero => BorderRect::ZERO,
SliceBorder::Uniform(n) => BorderRect::all(n),
SliceBorder::Sides {
top,
right,
bottom,
left,
} => BorderRect {
min_inset: Vec2::new(left, top),
max_inset: Vec2::new(right, bottom),
},
};
TextureSlicer {
border,
center_scale_mode: slice_scale(&spec.center_scale_mode),
sides_scale_mode: slice_scale(&spec.sides_scale_mode),
max_corner_scale: spec.max_corner_scale.unwrap_or(1.0),
}
}
fn slice_scale(mode: &Option<SliceScale>) -> SliceScaleMode {
match mode {
Some(SliceScale::Tile { tile }) => SliceScaleMode::Tile {
stretch_value: *tile,
},
_ => SliceScaleMode::Stretch,
}
}
fn line_height(spec: &LineHeightSpec) -> LineHeight {
match spec {
LineHeightSpec::Relative(scale) => LineHeight::RelativeToFont(*scale),
LineHeightSpec::Px { px } => LineHeight::Px(*px),
LineHeightSpec::Str(s) => {
let s = s.trim();
if let Some(n) = s.strip_suffix("px") {
if let Ok(v) = n.trim().parse() {
return LineHeight::Px(v);
}
} else {
let num = s
.strip_suffix("rem")
.or_else(|| s.strip_suffix("em"))
.unwrap_or(s);
if let Ok(v) = num.trim().parse() {
return LineHeight::RelativeToFont(v);
}
}
warn!("invalid lineHeight {s:?}; using the default");
LineHeight::default()
}
}
}
fn letter_spacing(spec: &LetterSpacingSpec) -> LetterSpacing {
match spec {
LetterSpacingSpec::Px(px) => LetterSpacing::Px(*px),
LetterSpacingSpec::Rem { rem } => LetterSpacing::Rem(*rem),
LetterSpacingSpec::Str(s) => {
let s = s.trim();
if s.eq_ignore_ascii_case("normal") {
return LetterSpacing::default();
}
if let Some(n) = s.strip_suffix("px") {
if let Ok(v) = n.trim().parse() {
return LetterSpacing::Px(v);
}
} else if let Some(n) = s.strip_suffix("rem").or_else(|| s.strip_suffix("em")) {
if let Ok(v) = n.trim().parse() {
return LetterSpacing::Rem(v);
}
} else if let Ok(v) = s.parse() {
return LetterSpacing::Px(v); }
warn!("invalid letterSpacing {s:?}; using the default");
LetterSpacing::default()
}
}
}
fn text_shadow(style: Option<&Style>) -> Option<TextShadow> {
let s = style?;
let spec = s.text_shadow.as_ref()?;
let mut shadow = TextShadow::default();
if let Some(x) = spec.offset_x {
shadow.offset.x = x;
}
if let Some(y) = spec.offset_y {
shadow.offset.y = y;
}
if let Some(c) = &spec.color {
shadow.color = parse_color(c);
}
shadow.color = apply_opacity(shadow.color, s.opacity);
Some(shadow)
}
pub fn resolved_text_style(
style: &Option<Style>,
fonts: &Fonts,
) -> (TextColor, TextFont, LineHeight, LetterSpacing) {
let mut color = TextColor(Color::WHITE);
let mut font = TextFont::default();
let mut line = LineHeight::default();
let mut spacing = LetterSpacing::default();
if let Some(h) = &fonts.default {
font.font = FontSource::Handle(h.clone());
}
if let Some(s) = style.as_ref() {
if let Some(c) = &s.color {
color = TextColor(parse_color(c));
}
if s.opacity.is_some() {
color = TextColor(apply_opacity(color.0, s.opacity));
}
if let Some(size) = s.font_size {
font.font_size = font_size_to_bevy(size);
}
if let Some(w) = s.font_weight {
font.weight = w;
}
if let Some(family) = &s.font_family {
match fonts.named.get(family) {
Some(h) => font.font = FontSource::Handle(h.clone()),
None => warn!("unknown fontFamily {family:?}; using the default font"),
}
}
if let Some(lh) = &s.line_height {
line = line_height(lh);
}
if let Some(ls) = &s.letter_spacing {
spacing = letter_spacing(ls);
}
}
(color, font, line, spacing)
}
pub fn apply_text_style(ec: &mut EntityCommands, style: &Option<Style>, fonts: &Fonts) {
ec.insert(resolved_text_style(style, fonts));
}
pub fn text_layout(style: &Option<Style>) -> Option<TextLayout> {
let s = style.as_ref()?;
if s.text_align.is_none() && s.line_break.is_none() {
return None;
}
Some(TextLayout {
justify: s.text_align.unwrap_or_default(),
linebreak: s.line_break.unwrap_or_default(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::{Props, Style};
#[test]
fn image_opacity_fades_tint_alpha() {
let mut app = App::new();
app.add_plugins((MinimalPlugins, AssetPlugin::default()));
app.init_asset::<Image>();
let assets = app.world().resource::<AssetServer>();
let props: Props = serde_json::from_value(serde_json::json!({
"tint": "#ff0000",
"style": { "opacity": 0.5 },
}))
.unwrap();
let image = image_node(&props, assets);
let c = image.color.to_srgba();
assert!(
(c.alpha - 0.5).abs() < 1e-6,
"alpha should be 0.5, got {}",
c.alpha
);
assert!((c.red - 1.0).abs() < 1e-6, "tint hue preserved");
}
#[test]
fn focus_policy_maps_with_pass_default() {
use bevy::ecs::world::CommandQueue;
let apply = |world: &mut World, entity: Entity, json: serde_json::Value| {
let style: Style = serde_json::from_value(json).unwrap();
let mut queue = CommandQueue::default();
let mut commands = Commands::new(&mut queue, world);
apply_style(&mut commands.entity(entity), &Some(style));
queue.apply(world);
};
let mut world = World::new();
let entity = world.spawn_empty().id();
apply(
&mut world,
entity,
serde_json::json!({ "focusPolicy": "block" }),
);
assert_eq!(world.get::<FocusPolicy>(entity), Some(&FocusPolicy::Block));
apply(
&mut world,
entity,
serde_json::json!({ "focusPolicy": "pass" }),
);
assert_eq!(world.get::<FocusPolicy>(entity), Some(&FocusPolicy::Pass));
apply(&mut world, entity, serde_json::json!({}));
assert_eq!(world.get::<FocusPolicy>(entity), Some(&FocusPolicy::Pass));
}
fn assets_app() -> App {
let mut app = App::new();
app.add_plugins((MinimalPlugins, AssetPlugin::default()));
app.init_asset::<Image>();
app
}
#[test]
fn image_mode_sliced_maps_to_texture_slicer() {
let app = assets_app();
let assets = app.world().resource::<AssetServer>();
let props: Props = serde_json::from_value(serde_json::json!({
"src": "modal.png",
"imageMode": {
"type": "sliced",
"border": { "top": 10.0, "right": 20.0, "bottom": 30.0, "left": 40.0 },
"sidesScaleMode": { "tile": 0.5 },
"maxCornerScale": 2.0,
},
}))
.unwrap();
let image = image_node(&props, assets);
match image.image_mode {
NodeImageMode::Sliced(s) => {
assert_eq!(s.border.min_inset, Vec2::new(40.0, 10.0));
assert_eq!(s.border.max_inset, Vec2::new(20.0, 30.0));
assert_eq!(s.max_corner_scale, 2.0);
assert_eq!(s.center_scale_mode, SliceScaleMode::Stretch);
assert_eq!(
s.sides_scale_mode,
SliceScaleMode::Tile { stretch_value: 0.5 }
);
}
other => panic!("expected Sliced, got {other:?}"),
}
}
#[test]
fn image_mode_tiled_and_uniform_border() {
let app = assets_app();
let assets = app.world().resource::<AssetServer>();
let sliced: Props = serde_json::from_value(serde_json::json!({
"src": "modal.png",
"imageMode": { "type": "sliced", "border": 16.0 },
}))
.unwrap();
match image_node(&sliced, assets).image_mode {
NodeImageMode::Sliced(s) => assert_eq!(s.border, BorderRect::all(16.0)),
other => panic!("expected Sliced, got {other:?}"),
}
let tiled: Props = serde_json::from_value(serde_json::json!({
"src": "modal.png",
"imageMode": { "type": "tiled", "tileX": true, "stretchValue": 2.0 },
}))
.unwrap();
match image_node(&tiled, assets).image_mode {
NodeImageMode::Tiled {
tile_x,
tile_y,
stretch_value,
} => {
assert!(tile_x);
assert!(!tile_y);
assert_eq!(stretch_value, 2.0);
}
other => panic!("expected Tiled, got {other:?}"),
}
}
#[test]
fn image_mode_keyword_backward_compatible() {
let app = assets_app();
let assets = app.world().resource::<AssetServer>();
let stretch: Props =
serde_json::from_value(serde_json::json!({ "imageMode": "stretch" })).unwrap();
assert!(matches!(
image_node(&stretch, assets).image_mode,
NodeImageMode::Stretch
));
let auto: Props =
serde_json::from_value(serde_json::json!({ "imageMode": "auto" })).unwrap();
assert!(matches!(
image_node(&auto, assets).image_mode,
NodeImageMode::Auto
));
}
#[test]
fn source_rect_and_visual_box_map() {
let app = assets_app();
let assets = app.world().resource::<AssetServer>();
let props: Props = serde_json::from_value(serde_json::json!({
"src": "logo.png",
"sourceRect": { "x": 10.0, "y": 20.0, "width": 30.0, "height": 40.0 },
"visualBox": "border",
}))
.unwrap();
let image = image_node(&props, assets);
assert_eq!(
image.rect,
Some(bevy::math::Rect::new(10.0, 20.0, 40.0, 60.0))
);
assert_eq!(image.visual_box, VisualBox::BorderBox);
}
#[test]
fn atlas_layout_cache_reuses_handle_across_index() {
let app = assets_app();
let assets = app.world().resource::<AssetServer>();
let mut layouts = Assets::<TextureAtlasLayout>::default();
let mut cache = AtlasLayoutCache::default();
let frame = |index: usize| -> Props {
serde_json::from_value(serde_json::json!({
"src": "sheet.png",
"atlas": {
"tileWidth": 32, "tileHeight": 32,
"columns": 4, "rows": 4, "index": index,
},
}))
.unwrap()
};
let p0 = frame(0);
let mut a = image_node(&p0, assets);
apply_atlas(&mut a, &p0, &mut layouts, &mut cache);
let p2 = frame(2);
let mut b = image_node(&p2, assets);
apply_atlas(&mut b, &p2, &mut layouts, &mut cache);
let ta = a.texture_atlas.expect("atlas on a");
let tb = b.texture_atlas.expect("atlas on b");
assert_eq!(ta.layout, tb.layout, "same grid reuses one layout handle");
assert_eq!(ta.index, 0);
assert_eq!(tb.index, 2);
assert_eq!(layouts.len(), 1, "only one layout asset created");
}
fn style(json: serde_json::Value) -> Style {
serde_json::from_value(json).unwrap()
}
#[test]
fn color_named_function_and_fallback() {
let red = parse_color("#ff0000").to_srgba();
assert_eq!(parse_color("red").to_srgba(), red);
assert_eq!(parse_color("rgb(255, 0, 0)").to_srgba(), red);
assert_eq!(parse_color("transparent").to_srgba().alpha, 0.0);
assert_eq!(
parse_color("definitely-not-a-color"),
Color::srgb(1.0, 0.0, 1.0)
);
}
#[test]
fn length_units() {
let s = style(serde_json::json!({
"width": "50%", "height": "auto", "maxWidth": "100vw", "minHeight": 24
}));
assert_eq!(length_to_val(s.width.unwrap()), Val::Percent(50.0));
assert_eq!(length_to_val(s.height.unwrap()), Val::Auto);
assert_eq!(length_to_val(s.max_width.unwrap()), Val::Vw(100.0));
assert_eq!(length_to_val(s.min_height.unwrap()), Val::Px(24.0));
}
#[test]
fn rect_shorthand_and_object() {
let s = style(serde_json::json!({
"padding": "8px 16px",
"margin": { "top": 1, "left": "2px" },
}));
let pad = rect_to_uirect(s.padding.unwrap());
assert_eq!(pad.top, Val::Px(8.0));
assert_eq!(pad.bottom, Val::Px(8.0));
assert_eq!(pad.left, Val::Px(16.0));
assert_eq!(pad.right, Val::Px(16.0));
let margin = rect_to_uirect(s.margin.unwrap());
assert_eq!(margin.top, Val::Px(1.0));
assert_eq!(margin.left, Val::Px(2.0));
}
#[test]
fn linear_gradient_maps_angle_and_stops() {
let s = style(serde_json::json!({
"backgroundGradient": {
"type": "linear",
"angle": 90.0,
"stops": [
{ "color": "#ff0000", "position": 0 },
{ "color": "#0000ff", "position": "100%" },
],
},
}));
let grads = build_gradients(s.background_gradient.as_ref().unwrap(), None);
assert_eq!(grads.len(), 1);
let Gradient::Linear(lin) = &grads[0] else {
panic!("expected a linear gradient, got {:?}", grads[0]);
};
assert!((lin.angle - 90.0_f32.to_radians()).abs() < 1e-6);
assert_eq!(lin.stops.len(), 2);
assert_eq!(lin.stops[0].point, Val::Px(0.0));
assert_eq!(lin.stops[1].point, Val::Percent(100.0));
assert_eq!(lin.stops[0].color.to_srgba(), Srgba::hex("ff0000").unwrap());
}
#[test]
fn gradient_accepts_single_or_array() {
let one = style(serde_json::json!({
"backgroundGradient": { "type": "linear", "stops": [{ "color": "#fff" }] },
}));
let many = style(serde_json::json!({
"backgroundGradient": [
{ "type": "linear", "stops": [{ "color": "#fff" }] },
{ "type": "radial", "stops": [{ "color": "#000" }] },
],
}));
assert_eq!(
build_gradients(one.background_gradient.as_ref().unwrap(), None).len(),
1
);
assert_eq!(
build_gradients(many.background_gradient.as_ref().unwrap(), None).len(),
2
);
}
#[test]
fn box_shadow_accepts_single_or_array() {
let one = style(serde_json::json!({
"boxShadow": { "color": "#000", "blurRadius": 8 },
}));
let many = style(serde_json::json!({
"boxShadow": [
{ "color": "#000", "blurRadius": 4 },
{ "color": "#FFFFFF33", "blurRadius": 16, "spreadRadius": 4 },
],
}));
assert_eq!(build_box_shadows(one.box_shadow.as_ref().unwrap()).len(), 1);
assert_eq!(
build_box_shadows(many.box_shadow.as_ref().unwrap()).len(),
2
);
}
#[test]
fn conic_gradient_converts_degrees_to_radians() {
let s = style(serde_json::json!({
"backgroundGradient": {
"type": "conic",
"start": 45.0,
"stops": [
{ "color": "#ff0000", "angle": 0.0 },
{ "color": "#00ff00", "angle": 180.0 },
],
},
}));
let grads = build_gradients(s.background_gradient.as_ref().unwrap(), None);
let Gradient::Conic(conic) = &grads[0] else {
panic!("expected a conic gradient, got {:?}", grads[0]);
};
assert!((conic.start - 45.0_f32.to_radians()).abs() < 1e-6);
assert_eq!(conic.stops[1].angle, Some(180.0_f32.to_radians()));
}
#[test]
fn line_height_px_vs_relative() {
let lh = |v: serde_json::Value| {
line_height(
style(serde_json::json!({ "lineHeight": v }))
.line_height
.as_ref()
.unwrap(),
)
};
assert_eq!(lh(serde_json::json!(1.5)), LineHeight::RelativeToFont(1.5));
assert_eq!(lh(serde_json::json!({ "px": 28 })), LineHeight::Px(28.0));
assert_eq!(lh(serde_json::json!("20px")), LineHeight::Px(20.0));
assert_eq!(
lh(serde_json::json!("1.5em")),
LineHeight::RelativeToFont(1.5)
);
}
#[test]
fn letter_spacing_px_vs_rem() {
let ls = |v: serde_json::Value| {
letter_spacing(
style(serde_json::json!({ "letterSpacing": v }))
.letter_spacing
.as_ref()
.unwrap(),
)
};
assert_eq!(ls(serde_json::json!(2)), LetterSpacing::Px(2.0));
assert_eq!(
ls(serde_json::json!({ "rem": 0.1 })),
LetterSpacing::Rem(0.1)
);
assert_eq!(ls(serde_json::json!("2px")), LetterSpacing::Px(2.0));
assert_eq!(ls(serde_json::json!("0.1rem")), LetterSpacing::Rem(0.1));
assert_eq!(ls(serde_json::json!("normal")), LetterSpacing::default());
}
#[test]
fn text_shadow_offset_and_color() {
let s = style(serde_json::json!({
"textShadow": { "color": "#ff0000", "offsetX": 2, "offsetY": 3 },
}));
let shadow = text_shadow(Some(&s)).unwrap();
assert_eq!(shadow.offset, Vec2::new(2.0, 3.0));
assert_eq!(shadow.color.to_srgba(), Srgba::hex("ff0000").unwrap());
let bare = style(serde_json::json!({ "textShadow": {} }));
assert_eq!(text_shadow(Some(&bare)).unwrap().offset, Vec2::splat(4.0));
assert!(text_shadow(Some(&style(serde_json::json!({})))).is_none());
}
#[test]
fn line_break_drives_layout() {
let s = style(serde_json::json!({ "lineBreak": "noWrap" }));
let layout = text_layout(&Some(s)).unwrap();
assert_eq!(layout.linebreak, LineBreak::NoWrap);
assert_eq!(layout.justify, Justify::default());
assert!(text_layout(&Some(style(serde_json::json!({})))).is_none());
let both = style(serde_json::json!({ "textAlign": "center", "lineBreak": "anyCharacter" }));
let layout = text_layout(&Some(both)).unwrap();
assert_eq!(layout.justify, Justify::Center);
assert_eq!(layout.linebreak, LineBreak::AnyCharacter);
}
#[test]
fn overlay_merges_fields() {
let base = Some(style(serde_json::json!({
"backgroundColor": "#111111",
"width": 64,
"color": "#ffffff",
})));
let unchanged = overlay_style(&base, &None).unwrap();
assert_eq!(unchanged.background_color.as_deref(), Some("#111111"));
let overlay = Some(style(serde_json::json!({ "backgroundColor": "#89b4fa" })));
let merged = overlay_style(&base, &overlay).unwrap();
assert_eq!(merged.background_color.as_deref(), Some("#89b4fa")); assert_eq!(length_to_val(merged.width.unwrap()), Val::Px(64.0)); assert_eq!(merged.color.as_deref(), Some("#ffffff"));
let from_none = overlay_style(&None, &overlay).unwrap();
assert_eq!(from_none.background_color.as_deref(), Some("#89b4fa"));
}
#[test]
fn overlay_skips_filter_and_focus_policy() {
let base = Some(style(serde_json::json!({
"filter": { "blur": 4 },
"focusPolicy": "block",
})));
let overlay = Some(style(serde_json::json!({
"filter": { "blur": 9 },
"focusPolicy": "pass",
"backgroundColor": "red",
})));
let merged = overlay_style(&base, &overlay).unwrap();
assert_eq!(
merged.filter.unwrap().blur,
base.as_ref().unwrap().filter.as_ref().unwrap().blur
);
assert_eq!(merged.focus_policy, Some(FocusPolicy::Block));
assert_eq!(merged.background_color.as_deref(), Some("red"));
}
#[test]
fn overlay_replaces_transform_wholesale() {
let base = Some(style(serde_json::json!({
"transform": { "translateX": 10, "scale": 1 },
})));
let press = Some(style(serde_json::json!({ "transform": { "scale": 0.95 } })));
let merged = overlay_style(&base, &press).unwrap();
let t = merged.transform.unwrap();
assert_eq!(t.scale, Some(0.95));
assert_eq!(t.translate_x, None);
}
#[test]
fn node_covers_layout() {
let s = style(serde_json::json!({
"display": "grid",
"positionType": "absolute",
"left": "10px",
"flexGrow": 2,
"gap": 12,
}));
let node = node_from_style(&Some(s));
assert_eq!(node.display, Display::Grid);
assert_eq!(node.position_type, PositionType::Absolute);
assert_eq!(node.left, Val::Px(10.0));
assert_eq!(node.flex_grow, 2.0);
assert_eq!(node.row_gap, Val::Px(12.0));
assert_eq!(node.column_gap, Val::Px(12.0));
}
#[test]
fn grid_templates_and_placement() {
let s = style(serde_json::json!({
"gridTemplateColumns": "1fr 2fr 100px",
"gridAutoRows": "auto 40px",
"gridRow": "2 / span 3",
}));
let node = node_from_style(&Some(s));
assert_eq!(node.grid_template_columns.len(), 3);
assert_eq!(node.grid_auto_rows.len(), 2);
assert_eq!(
format!("{:?}", node.grid_row),
format!("{:?}", GridPlacement::start_span(2, 3))
);
}
#[test]
fn text_style_resolves() {
let s = style(serde_json::json!({
"color": "#7aa2f7", "fontSize": 20, "fontWeight": "bold"
}));
let (color, font, ..) = resolved_text_style(&Some(s), &Fonts::default());
assert_eq!(color.0, parse_color("#7aa2f7"));
assert_eq!(font.font_size, (20.0f32).into());
assert_eq!(font.weight, FontWeight::BOLD);
}
#[test]
fn font_size_units() {
let fs = |v: serde_json::Value| {
resolved_text_style(
&Some(style(serde_json::json!({ "fontSize": v }))),
&Fonts::default(),
)
.1
.font_size
};
assert_eq!(fs(serde_json::json!(24)), BevyFontSize::Px(24.0));
assert_eq!(fs(serde_json::json!("24px")), BevyFontSize::Px(24.0));
assert_eq!(fs(serde_json::json!("2vw")), BevyFontSize::Vw(2.0));
assert_eq!(fs(serde_json::json!("1.5rem")), BevyFontSize::Rem(1.5));
}
#[test]
fn text_enums() {
let layout =
text_layout(&Some(style(serde_json::json!({ "textAlign": "right" })))).unwrap();
assert_eq!(layout.justify, Justify::Right);
}
}