use std::fmt;
use bevy::text::{FontWeight, Justify, LineBreak};
use bevy::ui::{
AlignContent, AlignItems, AlignSelf, BoxSizing, Display, FlexDirection, FlexWrap, FocusPolicy,
GridAutoFlow, GridPlacement, GridTrack, JustifyContent, JustifyItems, JustifySelf,
OverflowAxis, PositionType, RepeatedGridTrack,
};
use serde::de::{self, Deserializer, MapAccess, Visitor};
use serde::{Deserialize, Serialize};
pub type NodeId = u32;
pub const ROOT_ID: NodeId = 0;
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "op", rename_all = "camelCase")]
pub enum Op {
Reset,
Create {
id: NodeId,
kind: String,
#[serde(default)]
props: Props,
#[serde(default)]
text: Option<String>,
},
CreateText { id: NodeId, text: String },
CreateTextSpan { id: NodeId, text: String },
Append { parent: NodeId, child: NodeId },
Insert {
parent: NodeId,
child: NodeId,
before: NodeId,
},
Remove { parent: NodeId, child: NodeId },
Update {
id: NodeId,
#[serde(default)]
props: Props,
#[serde(default)]
unset: Vec<String>,
#[serde(default, rename = "styleUnset")]
style_unset: Vec<String>,
},
UpdateText { id: NodeId, text: String },
Draw { id: NodeId, cmds: Vec<DrawCmd> },
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Props {
#[serde(default)]
pub style: Option<Style>,
#[serde(default)]
pub hover_style: Option<Style>,
#[serde(default)]
pub press_style: Option<Style>,
#[serde(default)]
pub focus_style: Option<Style>,
#[serde(default)]
pub on_click: bool,
#[serde(default)]
pub on_pointer_down: bool,
#[serde(default)]
pub on_pointer_move: bool,
#[serde(default)]
pub on_pointer_up: bool,
#[serde(default)]
pub on_pointer_enter: bool,
#[serde(default)]
pub on_pointer_leave: bool,
#[serde(default)]
pub scroll_top: Option<f32>,
#[serde(default)]
pub scroll_left: Option<f32>,
#[serde(default)]
pub scroll_step: Option<f32>,
#[serde(default)]
pub on_scroll: bool,
#[serde(default)]
pub on_wheel: bool,
#[serde(default)]
pub animated: Option<crate::animations::AnimatedBindings>,
#[serde(default)]
pub anchor: Option<crate::anchor::Anchor>,
#[serde(default)]
pub src: Option<String>,
#[serde(default)]
pub tint: Option<String>,
#[serde(default)]
pub flip_x: bool,
#[serde(default)]
pub flip_y: bool,
#[serde(default)]
pub image_mode: Option<ImageMode>,
#[serde(default)]
pub source_rect: Option<SourceRect>,
#[serde(default)]
pub atlas: Option<AtlasSpec>,
#[serde(default)]
pub visual_box: Option<String>,
#[serde(default)]
pub draw: Option<Vec<DrawCmd>>,
#[serde(default)]
pub on_resize: bool,
#[serde(default)]
pub target: Option<String>,
#[serde(default)]
pub value: Option<String>,
#[serde(default)]
pub max_length: Option<usize>,
#[serde(default)]
pub multiline: bool,
#[serde(default)]
pub on_change: bool,
#[serde(default)]
pub autofocus: bool,
#[serde(default)]
pub selection_start: Option<usize>,
#[serde(default)]
pub selection_end: Option<usize>,
#[serde(default)]
pub aria_label: Option<String>,
#[serde(default)]
pub on_select: bool,
#[serde(default)]
pub on_focus: bool,
#[serde(default)]
pub on_blur: bool,
}
pub use crate::canvas::DrawCmd;
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Style {
#[serde(default, deserialize_with = "de_display")]
pub display: Option<Display>,
#[serde(default, deserialize_with = "de_box_sizing")]
pub box_sizing: Option<BoxSizing>,
#[serde(default, deserialize_with = "de_position_type")]
pub position_type: Option<PositionType>,
#[serde(default, deserialize_with = "de_overflow_axis")]
pub overflow_x: Option<OverflowAxis>,
#[serde(default, deserialize_with = "de_overflow_axis")]
pub overflow_y: Option<OverflowAxis>,
#[serde(default)]
pub scrollbar_width: Option<f32>,
#[serde(default)]
pub left: Option<Length>,
#[serde(default)]
pub right: Option<Length>,
#[serde(default)]
pub top: Option<Length>,
#[serde(default)]
pub bottom: Option<Length>,
#[serde(default)]
pub width: Option<Length>,
#[serde(default)]
pub height: Option<Length>,
#[serde(default)]
pub min_width: Option<Length>,
#[serde(default)]
pub min_height: Option<Length>,
#[serde(default)]
pub max_width: Option<Length>,
#[serde(default)]
pub max_height: Option<Length>,
#[serde(default)]
pub aspect_ratio: Option<f32>,
#[serde(default, deserialize_with = "de_align_items")]
pub align_items: Option<AlignItems>,
#[serde(default, deserialize_with = "de_justify_items")]
pub justify_items: Option<JustifyItems>,
#[serde(default, deserialize_with = "de_align_self")]
pub align_self: Option<AlignSelf>,
#[serde(default, deserialize_with = "de_justify_self")]
pub justify_self: Option<JustifySelf>,
#[serde(default, deserialize_with = "de_align_content")]
pub align_content: Option<AlignContent>,
#[serde(default, deserialize_with = "de_justify_content")]
pub justify_content: Option<JustifyContent>,
#[serde(default)]
pub margin: Option<Rect>,
#[serde(default)]
pub padding: Option<Rect>,
#[serde(default)]
pub border: Option<Rect>,
#[serde(default, deserialize_with = "de_flex_direction")]
pub flex_direction: Option<FlexDirection>,
#[serde(default, deserialize_with = "de_flex_wrap")]
pub flex_wrap: Option<FlexWrap>,
#[serde(default)]
pub flex_grow: Option<f32>,
#[serde(default)]
pub flex_shrink: Option<f32>,
#[serde(default)]
pub flex_basis: Option<Length>,
#[serde(default)]
pub gap: Option<Length>,
#[serde(default)]
pub row_gap: Option<Length>,
#[serde(default)]
pub column_gap: Option<Length>,
#[serde(default, deserialize_with = "de_grid_auto_flow")]
pub grid_auto_flow: Option<GridAutoFlow>,
#[serde(default, deserialize_with = "de_grid_template")]
pub grid_template_rows: Option<Vec<RepeatedGridTrack>>,
#[serde(default, deserialize_with = "de_grid_template")]
pub grid_template_columns: Option<Vec<RepeatedGridTrack>>,
#[serde(default, deserialize_with = "de_grid_auto_tracks")]
pub grid_auto_rows: Option<Vec<GridTrack>>,
#[serde(default, deserialize_with = "de_grid_auto_tracks")]
pub grid_auto_columns: Option<Vec<GridTrack>>,
#[serde(default, deserialize_with = "de_grid_placement")]
pub grid_row: Option<GridPlacement>,
#[serde(default, deserialize_with = "de_grid_placement")]
pub grid_column: Option<GridPlacement>,
#[serde(default)]
pub background_color: Option<String>,
#[serde(default)]
pub border_color: Option<BorderColorSpec>,
#[serde(default)]
pub border_radius: Option<Rect>,
#[serde(default)]
pub outline: Option<OutlineSpec>,
#[serde(default)]
pub box_shadow: Option<BoxShadowList>,
#[serde(default)]
pub filter: Option<FilterSpec>,
#[serde(default)]
pub background_gradient: Option<GradientList>,
#[serde(default)]
pub border_gradient: Option<GradientList>,
#[serde(default)]
pub z_index: Option<i32>,
#[serde(default)]
pub global_z_index: Option<i32>,
#[serde(default, deserialize_with = "de_focus_policy")]
pub focus_policy: Option<FocusPolicy>,
#[serde(default)]
pub cursor: Option<String>,
#[serde(default)]
pub transform: Option<Transform>,
#[serde(default)]
pub opacity: Option<f32>,
#[serde(default)]
pub transition: Option<crate::transition::Transition>,
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub font_size: Option<FontSize>,
#[serde(default, deserialize_with = "de_font_weight")]
pub font_weight: Option<FontWeight>,
#[serde(default)]
pub font_family: Option<String>,
#[serde(default, deserialize_with = "de_text_align")]
pub text_align: Option<Justify>,
#[serde(default)]
pub line_height: Option<LineHeightSpec>,
#[serde(default)]
pub letter_spacing: Option<LetterSpacingSpec>,
#[serde(default)]
pub text_shadow: Option<TextShadowSpec>,
#[serde(default, deserialize_with = "de_line_break")]
pub line_break: Option<LineBreak>,
}
pub mod style_groups {
pub const LAYOUT: u32 = 1 << 0;
pub const BACKGROUND: u32 = 1 << 1;
pub const TRANSFORM: u32 = 1 << 2;
pub const BORDER_COLOR: u32 = 1 << 3;
pub const OUTLINE: u32 = 1 << 4;
pub const BOX_SHADOW: u32 = 1 << 5;
pub const BG_GRADIENT: u32 = 1 << 6;
pub const BORDER_GRADIENT: u32 = 1 << 7;
pub const TEXT_SHADOW: u32 = 1 << 8;
pub const Z_INDEX: u32 = 1 << 9;
pub const GLOBAL_Z_INDEX: u32 = 1 << 10;
pub const FOCUS_POLICY: u32 = 1 << 11;
pub const FILTER: u32 = 1 << 12;
pub const TRANSITION: u32 = 1 << 13;
pub const SCROLL_TRANSITION: u32 = 1 << 14;
pub const TEXT: u32 = 1 << 15;
pub const TEXT_LAYOUT: u32 = 1 << 16;
pub const CURSOR: u32 = 1 << 17;
}
macro_rules! with_style_fields {
($cb:ident) => {
$cb! {
(display, "display", (LAYOUT), overlay),
(box_sizing, "boxSizing", (LAYOUT), overlay),
(position_type, "positionType", (LAYOUT), overlay),
(overflow_x, "overflowX", (LAYOUT), overlay),
(overflow_y, "overflowY", (LAYOUT), overlay),
(scrollbar_width, "scrollbarWidth", (LAYOUT), overlay),
(left, "left", (LAYOUT), overlay),
(right, "right", (LAYOUT), overlay),
(top, "top", (LAYOUT), overlay),
(bottom, "bottom", (LAYOUT), overlay),
(width, "width", (LAYOUT | TRANSITION), overlay),
(height, "height", (LAYOUT | TRANSITION), overlay),
(min_width, "minWidth", (LAYOUT), overlay),
(min_height, "minHeight", (LAYOUT), overlay),
(max_width, "maxWidth", (LAYOUT | TRANSITION), overlay),
(max_height, "maxHeight", (LAYOUT | TRANSITION), overlay),
(aspect_ratio, "aspectRatio", (LAYOUT), overlay),
(align_items, "alignItems", (LAYOUT), overlay),
(justify_items, "justifyItems", (LAYOUT), overlay),
(align_self, "alignSelf", (LAYOUT), overlay),
(justify_self, "justifySelf", (LAYOUT), overlay),
(align_content, "alignContent", (LAYOUT), overlay),
(justify_content, "justifyContent", (LAYOUT), overlay),
(margin, "margin", (LAYOUT), overlay),
(padding, "padding", (LAYOUT), overlay),
(border, "border", (LAYOUT), overlay),
(flex_direction, "flexDirection", (LAYOUT), overlay),
(flex_wrap, "flexWrap", (LAYOUT), overlay),
(flex_grow, "flexGrow", (LAYOUT), overlay),
(flex_shrink, "flexShrink", (LAYOUT), overlay),
(flex_basis, "flexBasis", (LAYOUT), overlay),
(gap, "gap", (LAYOUT), overlay),
(row_gap, "rowGap", (LAYOUT), overlay),
(column_gap, "columnGap", (LAYOUT), overlay),
(grid_auto_flow, "gridAutoFlow", (LAYOUT), overlay),
(grid_template_rows, "gridTemplateRows", (LAYOUT), overlay),
(grid_template_columns, "gridTemplateColumns", (LAYOUT), overlay),
(grid_auto_rows, "gridAutoRows", (LAYOUT), overlay),
(grid_auto_columns, "gridAutoColumns", (LAYOUT), overlay),
(grid_row, "gridRow", (LAYOUT), overlay),
(grid_column, "gridColumn", (LAYOUT), overlay),
(background_color, "backgroundColor", (BACKGROUND | TRANSITION), overlay),
(border_color, "borderColor", (BORDER_COLOR), overlay),
(border_radius, "borderRadius", (LAYOUT), overlay),
(outline, "outline", (OUTLINE), overlay),
(box_shadow, "boxShadow", (BOX_SHADOW), overlay),
(filter, "filter", (BACKGROUND | FILTER), no_overlay),
(background_gradient, "backgroundGradient", (BG_GRADIENT), overlay),
(border_gradient, "borderGradient", (BORDER_GRADIENT), overlay),
(z_index, "zIndex", (Z_INDEX), overlay),
(global_z_index, "globalZIndex", (GLOBAL_Z_INDEX), overlay),
(focus_policy, "focusPolicy", (FOCUS_POLICY), no_overlay),
(cursor, "cursor", (CURSOR), overlay),
(
transform,
"transform",
(TRANSFORM | TRANSITION),
overlay
),
(
opacity,
"opacity",
(BACKGROUND | BG_GRADIENT | BORDER_GRADIENT | TEXT_SHADOW | TRANSITION | TEXT),
overlay
),
(
transition,
"transition",
(TRANSITION | SCROLL_TRANSITION),
overlay
),
(color, "color", (TEXT), overlay),
(font_size, "fontSize", (TEXT), overlay),
(font_weight, "fontWeight", (TEXT), overlay),
(font_family, "fontFamily", (TEXT), overlay),
(text_align, "textAlign", (TEXT_LAYOUT), overlay),
(line_height, "lineHeight", (TEXT), overlay),
(letter_spacing, "letterSpacing", (TEXT), overlay),
(text_shadow, "textShadow", (TEXT_SHADOW), overlay),
(line_break, "lineBreak", (TEXT_LAYOUT), overlay),
}
};
}
pub(crate) use with_style_fields;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct StyleDirty(pub u32);
impl StyleDirty {
pub const NONE: Self = Self(0);
pub const ALL: Self = Self(u32::MAX);
pub fn intersects(self, groups: u32) -> bool {
self.0 & groups != 0
}
pub fn any(self) -> bool {
self.0 != 0
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct PropsDirty {
pub style: StyleDirty,
pub hover_style: bool,
pub press_style: bool,
pub focus_style: bool,
pub pointer: bool,
pub scroll_listener: bool,
pub wheel: bool,
pub scroll_step: bool,
pub animated: bool,
pub anchor: bool,
pub image: bool,
pub target: bool,
pub editable_handlers: bool,
pub aria_label: bool,
}
impl PropsDirty {
pub fn any_style_variant(&self) -> bool {
self.style.any() || self.hover_style || self.press_style || self.focus_style
}
}
#[derive(Debug, Default)]
pub struct UpdateEvents {
pub value: Option<String>,
pub selection_start: Option<usize>,
pub selection_end: Option<usize>,
pub scroll_top: Option<f32>,
pub scroll_left: Option<f32>,
pub draw: Option<Vec<DrawCmd>>,
}
impl Style {
pub(crate) fn overlay_delta(&mut self, delta: &Style) -> u32 {
let mut groups = 0u32;
macro_rules! merge_field {
($(($f:ident, $name:literal, $g:tt, $ov:ident),)*) => {
$(
if delta.$f.is_some() {
self.$f = delta.$f.clone();
groups |= {
use style_groups::*;
$g
};
}
)*
};
}
with_style_fields!(merge_field);
groups
}
pub(crate) fn unset_field(&mut self, wire_name: &str) -> Option<u32> {
macro_rules! unset_match {
($(($f:ident, $name:literal, $g:tt, $ov:ident),)*) => {
match wire_name {
$(
$name => {
self.$f = None;
Some({
use style_groups::*;
$g
})
}
)*
_ => {
tracing::warn!(
target: "bevy_react",
"unknown style field {wire_name:?} in styleUnset; ignoring"
);
None
}
}
};
}
with_style_fields!(unset_match)
}
}
impl Props {
pub fn split_events(mut self) -> (Props, UpdateEvents) {
let events = UpdateEvents {
value: self.value.take(),
selection_start: self.selection_start.take(),
selection_end: self.selection_end.take(),
scroll_top: self.scroll_top.take(),
scroll_left: self.scroll_left.take(),
draw: self.draw.take(),
};
(self, events)
}
pub fn merge_delta(
&mut self,
delta: Props,
unset: &[String],
style_unset: &[String],
) -> (PropsDirty, UpdateEvents) {
let mut dirty = PropsDirty::default();
let (delta, events) = delta.split_events();
if let Some(style_delta) = &delta.style {
let groups = self
.style
.get_or_insert_default()
.overlay_delta(style_delta);
dirty.style.0 |= groups;
}
if delta.hover_style.is_some() {
self.hover_style = delta.hover_style;
dirty.hover_style = true;
}
if delta.press_style.is_some() {
self.press_style = delta.press_style;
dirty.press_style = true;
}
if delta.focus_style.is_some() {
self.focus_style = delta.focus_style;
dirty.focus_style = true;
}
macro_rules! merge_bool {
($($f:ident => $flag:ident),* $(,)?) => {
$(
if delta.$f {
self.$f = true;
dirty.$flag = true;
}
)*
};
}
merge_bool!(
on_click => pointer,
on_pointer_down => pointer,
on_pointer_move => pointer,
on_pointer_up => pointer,
on_pointer_enter => pointer,
on_pointer_leave => pointer,
on_scroll => scroll_listener,
on_wheel => wheel,
on_change => editable_handlers,
on_select => editable_handlers,
on_focus => editable_handlers,
on_blur => editable_handlers,
flip_x => image,
flip_y => image,
);
if delta.multiline {
self.multiline = true;
}
if delta.autofocus {
self.autofocus = true;
}
if delta.on_resize {
self.on_resize = true;
}
macro_rules! merge_option {
($($f:ident => $($flag:ident)?),* $(,)?) => {
$(
if delta.$f.is_some() {
self.$f = delta.$f;
$( dirty.$flag = true; )?
}
)*
};
}
merge_option!(
scroll_step => scroll_step,
animated => animated,
anchor => anchor,
src => image,
tint => image,
image_mode => image,
source_rect => image,
atlas => image,
visual_box => image,
target => target,
aria_label => aria_label,
max_length => , );
for name in unset {
match name.as_str() {
"style" => {
self.style = None;
dirty.style = StyleDirty::ALL;
}
"hoverStyle" => {
self.hover_style = None;
dirty.hover_style = true;
}
"pressStyle" => {
self.press_style = None;
dirty.press_style = true;
}
"focusStyle" => {
self.focus_style = None;
dirty.focus_style = true;
}
"onClick" => {
self.on_click = false;
dirty.pointer = true;
}
"onPointerDown" => {
self.on_pointer_down = false;
dirty.pointer = true;
}
"onPointerMove" => {
self.on_pointer_move = false;
dirty.pointer = true;
}
"onPointerUp" => {
self.on_pointer_up = false;
dirty.pointer = true;
}
"onPointerEnter" => {
self.on_pointer_enter = false;
dirty.pointer = true;
}
"onPointerLeave" => {
self.on_pointer_leave = false;
dirty.pointer = true;
}
"onScroll" => {
self.on_scroll = false;
dirty.scroll_listener = true;
}
"onWheel" => {
self.on_wheel = false;
dirty.wheel = true;
}
"onChange" => {
self.on_change = false;
dirty.editable_handlers = true;
}
"onSelect" => {
self.on_select = false;
dirty.editable_handlers = true;
}
"onFocus" => {
self.on_focus = false;
dirty.editable_handlers = true;
}
"onBlur" => {
self.on_blur = false;
dirty.editable_handlers = true;
}
"flipX" => {
self.flip_x = false;
dirty.image = true;
}
"flipY" => {
self.flip_y = false;
dirty.image = true;
}
"multiline" => self.multiline = false,
"autofocus" => self.autofocus = false,
"onResize" => self.on_resize = false,
"scrollStep" => {
self.scroll_step = None;
dirty.scroll_step = true;
}
"animated" => {
self.animated = None;
dirty.animated = true;
}
"anchor" => {
self.anchor = None;
dirty.anchor = true;
}
"src" => {
self.src = None;
dirty.image = true;
}
"tint" => {
self.tint = None;
dirty.image = true;
}
"imageMode" => {
self.image_mode = None;
dirty.image = true;
}
"sourceRect" => {
self.source_rect = None;
dirty.image = true;
}
"atlas" => {
self.atlas = None;
dirty.image = true;
}
"visualBox" => {
self.visual_box = None;
dirty.image = true;
}
"target" => {
self.target = None;
dirty.target = true;
}
"ariaLabel" => {
self.aria_label = None;
dirty.aria_label = true;
}
"maxLength" => self.max_length = None,
"value" | "selectionStart" | "selectionEnd" | "scrollTop" | "scrollLeft"
| "draw" => {
tracing::warn!(
target: "bevy_react",
"event-like prop {name:?} in unset; nothing to reset"
);
}
other => {
tracing::warn!(
target: "bevy_react",
"unknown prop {other:?} in unset; ignoring"
);
}
}
}
if !style_unset.is_empty() {
let style = self.style.get_or_insert_default();
for name in style_unset {
if let Some(groups) = style.unset_field(name) {
dirty.style.0 |= groups;
}
}
}
(dirty, events)
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OutlineSpec {
#[serde(default)]
pub width: Option<Length>,
#[serde(default)]
pub offset: Option<Length>,
#[serde(default)]
pub color: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BoxShadowSpec {
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub x_offset: Option<Length>,
#[serde(default)]
pub y_offset: Option<Length>,
#[serde(default)]
pub spread_radius: Option<Length>,
#[serde(default)]
pub blur_radius: Option<Length>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum BoxShadowList {
One(BoxShadowSpec),
Many(Vec<BoxShadowSpec>),
}
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FilterSpec {
#[serde(default)]
pub blur: Option<Length>,
#[serde(default)]
pub brightness: Option<f32>,
#[serde(default)]
pub contrast: Option<f32>,
#[serde(default)]
pub saturate: Option<f32>,
#[serde(default)]
pub grayscale: Option<f32>,
#[serde(default)]
pub sepia: Option<f32>,
#[serde(default)]
pub invert: Option<f32>,
#[serde(default)]
pub hue_rotate: Option<Angle>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum LineHeightSpec {
Relative(f32),
Px { px: f32 },
Str(String),
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum LetterSpacingSpec {
Px(f32),
Rem { rem: f32 },
Str(String),
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextShadowSpec {
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub offset_x: Option<f32>,
#[serde(default)]
pub offset_y: Option<f32>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GradientStop {
pub color: String,
#[serde(default)]
pub position: Option<Length>,
#[serde(default)]
pub hint: Option<f32>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AngularStop {
pub color: String,
#[serde(default)]
pub angle: Option<Angle>,
#[serde(default)]
pub hint: Option<f32>,
}
pub type GradientPosition = String;
pub type ColorSpace = String;
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RadialShapeSpec {
Keyword(String),
Circle { circle: Length },
Ellipse { ellipse: [Length; 2] },
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LinearGradientSpec {
#[serde(default)]
pub angle: Option<Angle>,
#[serde(default)]
pub stops: Vec<GradientStop>,
#[serde(default)]
pub color_space: Option<ColorSpace>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RadialGradientSpec {
#[serde(default)]
pub position: Option<GradientPosition>,
#[serde(default)]
pub shape: Option<RadialShapeSpec>,
#[serde(default)]
pub stops: Vec<GradientStop>,
#[serde(default)]
pub color_space: Option<ColorSpace>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConicGradientSpec {
#[serde(default)]
pub start: Option<Angle>,
#[serde(default)]
pub position: Option<GradientPosition>,
#[serde(default)]
pub stops: Vec<AngularStop>,
#[serde(default)]
pub color_space: Option<ColorSpace>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum GradientSpec {
Linear(LinearGradientSpec),
Radial(RadialGradientSpec),
Conic(ConicGradientSpec),
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum GradientList {
One(GradientSpec),
Many(Vec<GradientSpec>),
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum ImageMode {
Keyword(String),
Spec(ImageModeSpec),
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum ImageModeSpec {
Sliced(SliceSpec),
Tiled(TiledSpec),
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SliceSpec {
#[serde(default)]
pub border: SliceBorder,
#[serde(default)]
pub center_scale_mode: Option<SliceScale>,
#[serde(default)]
pub sides_scale_mode: Option<SliceScale>,
#[serde(default)]
pub max_corner_scale: Option<f32>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(untagged)]
pub enum SliceBorder {
#[default]
Zero,
Uniform(f32),
Sides {
#[serde(default)]
top: f32,
#[serde(default)]
right: f32,
#[serde(default)]
bottom: f32,
#[serde(default)]
left: f32,
},
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum SliceScale {
Keyword(String),
Tile { tile: f32 },
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TiledSpec {
#[serde(default)]
pub tile_x: bool,
#[serde(default)]
pub tile_y: bool,
#[serde(default)]
pub stretch_value: Option<f32>,
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SourceRect {
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AtlasSpec {
pub tile_width: u32,
pub tile_height: u32,
pub columns: u32,
pub rows: u32,
#[serde(default)]
pub padding: Option<[u32; 2]>,
#[serde(default)]
pub offset: Option<[u32; 2]>,
#[serde(default)]
pub index: usize,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Transform {
pub translate_x: Option<Length>,
pub translate_y: Option<Length>,
pub scale: Option<f32>,
pub scale_x: Option<f32>,
pub scale_y: Option<f32>,
pub rotate: Option<Angle>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Length {
Auto,
Px(f32),
Percent(f32),
Vw(f32),
Vh(f32),
VMin(f32),
VMax(f32),
}
impl Default for Length {
fn default() -> Self {
Length::Px(0.0)
}
}
fn parse_length(s: &str) -> Result<Length, String> {
let s = s.trim();
if s.eq_ignore_ascii_case("auto") {
return Ok(Length::Auto);
}
type LengthCtor = fn(f32) -> Length;
let units: [(&str, LengthCtor); 6] = [
("px", Length::Px),
("vmin", Length::VMin),
("vmax", Length::VMax),
("vw", Length::Vw),
("vh", Length::Vh),
("%", Length::Percent),
];
for (suffix, ctor) in units {
if let Some(num) = s.strip_suffix(suffix) {
let v: f32 = num
.trim()
.parse()
.map_err(|_| format!("invalid length {s:?}"))?;
return Ok(ctor(v));
}
}
s.parse::<f32>()
.map(Length::Px)
.map_err(|_| format!("invalid length {s:?}"))
}
impl<'de> Deserialize<'de> for Length {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct LengthVisitor;
impl<'de> Visitor<'de> for LengthVisitor {
type Value = Length;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a number (logical pixels) or a CSS length string")
}
fn visit_f64<E: de::Error>(self, v: f64) -> Result<Length, E> {
Ok(Length::Px(v as f32))
}
fn visit_i64<E: de::Error>(self, v: i64) -> Result<Length, E> {
Ok(Length::Px(v as f32))
}
fn visit_u64<E: de::Error>(self, v: u64) -> Result<Length, E> {
Ok(Length::Px(v as f32))
}
fn visit_str<E: de::Error>(self, s: &str) -> Result<Length, E> {
Ok(parse_length(s).unwrap_or_else(|e| {
tracing::warn!(target: "bevy_react", "{e}; using the default");
Length::default()
}))
}
}
d.deserialize_any(LengthVisitor)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct Angle(f32);
impl Angle {
pub fn radians(self) -> f32 {
self.0
}
}
fn parse_angle(s: &str) -> Result<f32, String> {
use std::f32::consts::{PI, TAU};
let s = s.trim();
type AngleConv = fn(f32) -> f32;
let units: [(&str, AngleConv); 4] = [
("deg", f32::to_radians),
("grad", |v| v * PI / 200.0),
("turn", |v| v * TAU),
("rad", |v| v),
];
for (suffix, conv) in units {
if let Some(num) = s.strip_suffix(suffix) {
let v: f32 = num
.trim()
.parse()
.map_err(|_| format!("invalid angle {s:?}"))?;
return Ok(conv(v));
}
}
s.parse::<f32>()
.map(f32::to_radians)
.map_err(|_| format!("invalid angle {s:?}"))
}
impl<'de> Deserialize<'de> for Angle {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct AngleVisitor;
impl Visitor<'_> for AngleVisitor {
type Value = Angle;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a number (degrees) or a CSS angle string")
}
fn visit_f64<E: de::Error>(self, v: f64) -> Result<Angle, E> {
Ok(Angle((v as f32).to_radians()))
}
fn visit_i64<E: de::Error>(self, v: i64) -> Result<Angle, E> {
Ok(Angle((v as f32).to_radians()))
}
fn visit_u64<E: de::Error>(self, v: u64) -> Result<Angle, E> {
Ok(Angle((v as f32).to_radians()))
}
fn visit_str<E: de::Error>(self, s: &str) -> Result<Angle, E> {
Ok(parse_angle(s).map(Angle).unwrap_or_else(|e| {
tracing::warn!(target: "bevy_react", "{e}; using the default");
Angle::default()
}))
}
}
d.deserialize_any(AngleVisitor)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct Time(f32);
impl Time {
pub fn from_secs(secs: f32) -> Self {
Time(secs)
}
pub fn seconds(self) -> f32 {
self.0
}
}
fn parse_time(s: &str) -> Result<f32, String> {
let s = s.trim();
if let Some(num) = s.strip_suffix("ms") {
return num
.trim()
.parse::<f32>()
.map(|v| v / 1000.0)
.map_err(|_| format!("invalid time {s:?}"));
}
if let Some(num) = s.strip_suffix('s') {
return num
.trim()
.parse::<f32>()
.map_err(|_| format!("invalid time {s:?}"));
}
s.parse::<f32>()
.map(|v| v / 1000.0)
.map_err(|_| format!("invalid time {s:?}"))
}
impl<'de> Deserialize<'de> for Time {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct TimeVisitor;
impl Visitor<'_> for TimeVisitor {
type Value = Time;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a number (milliseconds) or a CSS time string")
}
fn visit_f64<E: de::Error>(self, v: f64) -> Result<Time, E> {
Ok(Time(v as f32 / 1000.0))
}
fn visit_i64<E: de::Error>(self, v: i64) -> Result<Time, E> {
Ok(Time(v as f32 / 1000.0))
}
fn visit_u64<E: de::Error>(self, v: u64) -> Result<Time, E> {
Ok(Time(v as f32 / 1000.0))
}
fn visit_str<E: de::Error>(self, s: &str) -> Result<Time, E> {
Ok(parse_time(s).map(Time).unwrap_or_else(|e| {
tracing::warn!(target: "bevy_react", "{e}; using the default");
Time::default()
}))
}
}
d.deserialize_any(TimeVisitor)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum FontSize {
Px(f32),
Vw(f32),
Vh(f32),
VMin(f32),
VMax(f32),
Rem(f32),
}
fn parse_font_size(s: &str) -> Result<FontSize, String> {
let s = s.trim();
type FsCtor = fn(f32) -> FontSize;
let units: [(&str, FsCtor); 6] = [
("px", FontSize::Px),
("rem", FontSize::Rem),
("vmin", FontSize::VMin),
("vmax", FontSize::VMax),
("vw", FontSize::Vw),
("vh", FontSize::Vh),
];
for (suffix, ctor) in units {
if let Some(num) = s.strip_suffix(suffix) {
let v: f32 = num
.trim()
.parse()
.map_err(|_| format!("invalid fontSize {s:?}"))?;
return Ok(ctor(v));
}
}
s.parse::<f32>()
.map(FontSize::Px)
.map_err(|_| format!("invalid fontSize {s:?}"))
}
impl<'de> Deserialize<'de> for FontSize {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct FontSizeVisitor;
impl Visitor<'_> for FontSizeVisitor {
type Value = FontSize;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a number (logical pixels) or a font-size unit string")
}
fn visit_f64<E: de::Error>(self, v: f64) -> Result<FontSize, E> {
Ok(FontSize::Px(v as f32))
}
fn visit_i64<E: de::Error>(self, v: i64) -> Result<FontSize, E> {
Ok(FontSize::Px(v as f32))
}
fn visit_u64<E: de::Error>(self, v: u64) -> Result<FontSize, E> {
Ok(FontSize::Px(v as f32))
}
fn visit_str<E: de::Error>(self, s: &str) -> Result<FontSize, E> {
Ok(parse_font_size(s).unwrap_or_else(|e| {
tracing::warn!(target: "bevy_react", "{e}; using the default");
FontSize::Px(0.0)
}))
}
}
d.deserialize_any(FontSizeVisitor)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct Rect {
pub top: Length,
pub right: Length,
pub bottom: Length,
pub left: Length,
}
impl Rect {
fn uniform(v: Length) -> Self {
Rect {
top: v,
right: v,
bottom: v,
left: v,
}
}
fn from_shorthand(values: &[Length]) -> Result<Self, String> {
Ok(match values {
[a] => Rect::uniform(*a),
[a, b] => Rect {
top: *a,
bottom: *a,
right: *b,
left: *b,
},
[a, b, c] => Rect {
top: *a,
right: *b,
left: *b,
bottom: *c,
},
[a, b, c, d] => Rect {
top: *a,
right: *b,
bottom: *c,
left: *d,
},
_ => return Err("expected 1–4 length values".into()),
})
}
}
impl<'de> Deserialize<'de> for Rect {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct RectVisitor;
impl<'de> Visitor<'de> for RectVisitor {
type Value = Rect;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a number, a CSS shorthand string, or a {top,right,bottom,left} object")
}
fn visit_f64<E: de::Error>(self, v: f64) -> Result<Rect, E> {
Ok(Rect::uniform(Length::Px(v as f32)))
}
fn visit_i64<E: de::Error>(self, v: i64) -> Result<Rect, E> {
Ok(Rect::uniform(Length::Px(v as f32)))
}
fn visit_u64<E: de::Error>(self, v: u64) -> Result<Rect, E> {
Ok(Rect::uniform(Length::Px(v as f32)))
}
fn visit_str<E: de::Error>(self, s: &str) -> Result<Rect, E> {
let values: Vec<Length> = s
.split_whitespace()
.map(|tok| {
parse_length(tok).unwrap_or_else(|e| {
tracing::warn!(target: "bevy_react", "{e}; using the default");
Length::default()
})
})
.collect();
Ok(Rect::from_shorthand(&values).unwrap_or_else(|e| {
tracing::warn!(target: "bevy_react", "invalid rect {s:?}: {e}; using the default");
Rect::default()
}))
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Rect, A::Error> {
let mut rect = Rect::default();
while let Some(key) = map.next_key::<String>()? {
let v = map.next_value::<Length>()?;
match key.as_str() {
"top" => rect.top = v,
"right" => rect.right = v,
"bottom" => rect.bottom = v,
"left" => rect.left = v,
_ => tracing::warn!(
target: "bevy_react",
"unknown rect side {key:?}; ignoring (expected top/right/bottom/left)"
),
}
}
Ok(rect)
}
}
d.deserialize_any(RectVisitor)
}
}
macro_rules! keyword_fields {
( $(
$(#[$meta:meta])*
fn $fn_name:ident($kind:literal) -> $ty:ty {
$( $($kw:literal)|+ => $variant:ident ),+ $(,)?
}
)+ ) => { $(
$(#[$meta])*
fn $fn_name<'de, D: Deserializer<'de>>(d: D) -> Result<Option<$ty>, D::Error> {
struct V;
impl<'de> Visitor<'de> for V {
type Value = Option<$ty>;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str(concat!("a `", $kind, "` keyword string"))
}
fn visit_str<E: de::Error>(self, s: &str) -> Result<Self::Value, E> {
Ok(Some(match s {
$( $($kw)|+ => <$ty>::$variant, )+
_ => {
tracing::warn!(
target: "bevy_react",
"unrecognized {} {s:?}; using the default", $kind
);
<$ty>::default()
}
}))
}
fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
Ok(None)
}
fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
Ok(None)
}
}
d.deserialize_any(V)
}
)+ };
}
keyword_fields! {
fn de_display("display") -> Display {
"flex" => Flex, "grid" => Grid, "block" => Block, "none" => None,
}
fn de_box_sizing("boxSizing") -> BoxSizing {
"borderBox" | "border-box" => BorderBox,
"contentBox" | "content-box" => ContentBox,
}
fn de_position_type("positionType") -> PositionType {
"absolute" => Absolute, "relative" => Relative,
}
fn de_overflow_axis("overflow") -> OverflowAxis {
"visible" => Visible, "clip" => Clip, "hidden" => Hidden, "scroll" => Scroll,
}
fn de_align_items("alignItems") -> AlignItems {
"start" => Start, "end" => End,
"flexStart" => FlexStart, "flexEnd" => FlexEnd,
"center" => Center, "baseline" => Baseline, "stretch" => Stretch,
}
fn de_justify_items("justifyItems") -> JustifyItems {
"start" => Start, "end" => End,
"center" => Center, "baseline" => Baseline, "stretch" => Stretch,
}
fn de_align_self("alignSelf") -> AlignSelf {
"auto" => Auto, "start" => Start, "end" => End,
"flexStart" => FlexStart, "flexEnd" => FlexEnd,
"center" => Center, "baseline" => Baseline, "stretch" => Stretch,
}
fn de_justify_self("justifySelf") -> JustifySelf {
"auto" => Auto, "start" => Start, "end" => End,
"center" => Center, "baseline" => Baseline, "stretch" => Stretch,
}
fn de_align_content("alignContent") -> AlignContent {
"start" => Start, "end" => End,
"flexStart" => FlexStart, "flexEnd" => FlexEnd,
"center" => Center, "stretch" => Stretch,
"spaceBetween" => SpaceBetween, "spaceEvenly" => SpaceEvenly,
"spaceAround" => SpaceAround,
}
fn de_justify_content("justifyContent") -> JustifyContent {
"start" => Start, "end" => End,
"flexStart" => FlexStart, "flexEnd" => FlexEnd,
"center" => Center, "stretch" => Stretch,
"spaceBetween" => SpaceBetween, "spaceEvenly" => SpaceEvenly,
"spaceAround" => SpaceAround,
}
fn de_flex_direction("flexDirection") -> FlexDirection {
"row" => Row, "column" => Column,
"rowReverse" => RowReverse, "columnReverse" => ColumnReverse,
}
fn de_flex_wrap("flexWrap") -> FlexWrap {
"nowrap" | "noWrap" => NoWrap, "wrap" => Wrap, "wrapReverse" => WrapReverse,
}
fn de_grid_auto_flow("gridAutoFlow") -> GridAutoFlow {
"row" => Row, "column" => Column,
"rowDense" => RowDense, "columnDense" => ColumnDense,
}
fn de_focus_policy("focusPolicy") -> FocusPolicy {
"block" => Block, "pass" => Pass,
}
fn de_text_align("textAlign") -> Justify {
"left" => Left, "center" => Center, "right" => Right,
"justify" => Justified, "start" => Start, "end" => End,
}
fn de_line_break("lineBreak") -> LineBreak {
"wordBoundary" => WordBoundary, "anyCharacter" => AnyCharacter,
"wordOrCharacter" => WordOrCharacter, "noWrap" => NoWrap,
}
}
fn de_font_weight<'de, D: Deserializer<'de>>(d: D) -> Result<Option<FontWeight>, D::Error> {
struct V;
impl<'de> Visitor<'de> for V {
type Value = Option<FontWeight>;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a `fontWeight` keyword or numeric weight string")
}
fn visit_str<E: de::Error>(self, s: &str) -> Result<Self::Value, E> {
Ok(Some(match s {
"thin" => FontWeight::THIN,
"light" => FontWeight(300),
"normal" => FontWeight::NORMAL,
"medium" => FontWeight(500),
"semibold" => FontWeight(600),
"bold" => FontWeight::BOLD,
"black" => FontWeight::BLACK,
other => other.parse::<u16>().map(FontWeight).unwrap_or_else(|_| {
tracing::warn!(
target: "bevy_react",
"unrecognized fontWeight {other:?}; using the default"
);
FontWeight::NORMAL
}),
}))
}
fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
Ok(None)
}
fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
Ok(None)
}
}
d.deserialize_any(V)
}
fn split_tracks(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut depth = 0usize;
let mut cur = String::new();
for ch in s.chars() {
match ch {
'(' => {
depth += 1;
cur.push(ch);
}
')' => {
depth = depth.saturating_sub(1);
cur.push(ch);
}
c if c.is_whitespace() && depth == 0 => {
if !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
}
c => cur.push(c),
}
}
if !cur.is_empty() {
out.push(cur);
}
out
}
fn single_track(token: &str) -> Option<GridTrack> {
let t = token.trim();
match t {
"auto" => return Some(GridTrack::auto()),
"min-content" => return Some(GridTrack::min_content()),
"max-content" => return Some(GridTrack::max_content()),
_ => {}
}
let parse = |num: &str| num.trim().parse::<f32>().ok();
if let Some(v) = t.strip_suffix("fr").and_then(parse) {
Some(GridTrack::fr(v))
} else if let Some(v) = t.strip_suffix("flex").and_then(parse) {
Some(GridTrack::flex(v))
} else if let Some(v) = t.strip_suffix("px").and_then(parse) {
Some(GridTrack::px(v))
} else {
t.strip_suffix('%').and_then(parse).map(GridTrack::percent)
}
}
fn repeated_track(count: u16, token: &str) -> Option<RepeatedGridTrack> {
let t = token.trim();
match t {
"auto" => return Some(RepeatedGridTrack::auto(count)),
"min-content" => return Some(RepeatedGridTrack::min_content(count)),
"max-content" => return Some(RepeatedGridTrack::max_content(count)),
_ => {}
}
let parse = |num: &str| num.trim().parse::<f32>().ok();
if let Some(v) = t.strip_suffix("fr").and_then(parse) {
Some(RepeatedGridTrack::fr(count, v))
} else if let Some(v) = t.strip_suffix("flex").and_then(parse) {
Some(RepeatedGridTrack::flex(count, v))
} else if let Some(v) = t.strip_suffix("px").and_then(parse) {
Some(RepeatedGridTrack::px(count as usize, v))
} else {
t.strip_suffix('%')
.and_then(parse)
.map(|v| RepeatedGridTrack::percent(count as usize, v))
}
}
fn parse_template(s: &str) -> Vec<RepeatedGridTrack> {
split_tracks(s)
.into_iter()
.filter_map(|tok| {
let parse_one = || {
if let Some(inner) = tok
.strip_prefix("repeat(")
.and_then(|t| t.strip_suffix(')'))
{
let (count, track) = inner.split_once(',')?;
repeated_track(count.trim().parse().ok()?, track)
} else {
single_track(&tok).map(Into::into)
}
};
let parsed = parse_one();
if parsed.is_none() {
tracing::warn!(target: "bevy_react", "ignoring unparsable grid track {tok:?}");
}
parsed
})
.collect()
}
fn parse_auto_tracks(s: &str) -> Vec<GridTrack> {
split_tracks(s)
.iter()
.filter_map(|t| {
let parsed = single_track(t);
if parsed.is_none() {
tracing::warn!(target: "bevy_react", "ignoring unparsable grid track {t:?}");
}
parsed
})
.collect()
}
fn try_grid_placement(s: &str) -> Option<GridPlacement> {
enum Token {
Num(i16), Span(u16), Auto,
Invalid, }
fn token(t: &str) -> Token {
let t = t.trim();
if t == "auto" {
return Token::Auto;
}
if let Some(n) = t.strip_prefix("span") {
return match n.trim().parse::<u16>() {
Ok(0) | Err(_) => Token::Invalid,
Ok(n) => Token::Span(n),
};
}
match t.parse::<i16>() {
Ok(0) | Err(_) => Token::Invalid,
Ok(n) => Token::Num(n),
}
}
use Token::*;
if let Some((a, b)) = s.split_once('/') {
return Some(match (token(a), token(b)) {
(Num(start), Span(span)) => GridPlacement::start_span(start, span),
(Auto, Span(span)) => GridPlacement::span(span),
(Num(start), Num(end)) => GridPlacement::start_end(start, end),
(Num(start), Auto) => GridPlacement::start(start),
(Auto, Num(end)) => GridPlacement::end(end),
(Auto, Auto) => GridPlacement::auto(),
_ => return None,
});
}
match token(s) {
Auto => Some(GridPlacement::auto()),
Span(span) => Some(GridPlacement::span(span)),
Num(line) => Some(GridPlacement::start(line)),
Invalid => None,
}
}
macro_rules! grid_fields {
( $(
$(#[$meta:meta])*
fn $fn_name:ident($expect:literal) -> $ty:ty { $parse:expr }
)+ ) => { $(
$(#[$meta])*
fn $fn_name<'de, D: Deserializer<'de>>(d: D) -> Result<Option<$ty>, D::Error> {
struct V;
impl<'de> Visitor<'de> for V {
type Value = Option<$ty>;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str($expect)
}
fn visit_str<E: de::Error>(self, s: &str) -> Result<Self::Value, E> {
let parse: fn(&str) -> $ty = $parse;
Ok(Some(parse(s)))
}
fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
Ok(None)
}
fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
Ok(None)
}
}
d.deserialize_any(V)
}
)+ };
}
grid_fields! {
fn de_grid_template("a CSS grid template string") -> Vec<RepeatedGridTrack> {
parse_template
}
fn de_grid_auto_tracks("a grid auto-track list string") -> Vec<GridTrack> {
parse_auto_tracks
}
fn de_grid_placement("a grid line placement string") -> GridPlacement {
|s| {
try_grid_placement(s).unwrap_or_else(|| {
tracing::warn!(
target: "bevy_react",
"unrecognized grid placement {s:?}; using the default"
);
GridPlacement::default()
})
}
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct BorderColorSpec {
pub top: Option<String>,
pub right: Option<String>,
pub bottom: Option<String>,
pub left: Option<String>,
}
impl BorderColorSpec {
fn uniform(s: String) -> Self {
BorderColorSpec {
top: Some(s.clone()),
right: Some(s.clone()),
bottom: Some(s.clone()),
left: Some(s),
}
}
}
impl<'de> Deserialize<'de> for BorderColorSpec {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct BorderColorVisitor;
impl<'de> Visitor<'de> for BorderColorVisitor {
type Value = BorderColorSpec;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a CSS color string or a {top,right,bottom,left} object of colors")
}
fn visit_str<E: de::Error>(self, s: &str) -> Result<BorderColorSpec, E> {
Ok(BorderColorSpec::uniform(s.to_owned()))
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<BorderColorSpec, A::Error> {
let mut spec = BorderColorSpec::default();
while let Some(key) = map.next_key::<String>()? {
let v = map.next_value::<String>()?;
match key.as_str() {
"top" => spec.top = Some(v),
"right" => spec.right = Some(v),
"bottom" => spec.bottom = Some(v),
"left" => spec.left = Some(v),
_ => tracing::warn!(
target: "bevy_react",
"unknown borderColor side {key:?}; ignoring (expected top/right/bottom/left)"
),
}
}
Ok(spec)
}
}
d.deserialize_any(BorderColorVisitor)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UiEvent {
pub id: NodeId,
pub kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub x: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub y: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_x: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_y: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub button: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selection_start: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selection_end: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selection_direction: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub composing: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scroll_top: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scroll_left: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delta_x: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delta_y: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delta_mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub width: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub height: Option<f32>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "t", rename_all = "camelCase")]
pub enum Outbound {
UiEvent { event: UiEvent },
Event {
name: String,
value: serde_json::Value,
},
Response { id: u64, result: ResponseResult },
AnimationFinished {
id: crate::animations::SharedId,
token: u64,
finished: bool,
},
Reload,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "status", rename_all = "camelCase")]
pub enum ResponseResult {
Ok { value: serde_json::Value },
Err { message: String },
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deserializes_editable_text_create() {
let json = r#"{"op":"create","id":7,"kind":"editableText","props":{
"value":"hi","maxLength":40,"multiline":true,"onChange":true,
"autofocus":true,"selectionStart":0,"selectionEnd":2,
"ariaLabel":"Name","onSelect":true,"onFocus":true,"onBlur":true,
"focusStyle":{"borderColor":"white"}}}"#;
match serde_json::from_str::<Op>(json).expect("valid op") {
Op::Create {
id, kind, props, ..
} => {
assert_eq!(id, 7);
assert_eq!(kind, "editableText");
assert_eq!(props.value.as_deref(), Some("hi"));
assert_eq!(props.max_length, Some(40));
assert!(props.multiline);
assert!(props.on_change);
assert!(props.autofocus);
assert_eq!(props.selection_start, Some(0));
assert_eq!(props.selection_end, Some(2));
assert_eq!(props.aria_label.as_deref(), Some("Name"));
assert!(props.on_select);
assert!(props.on_focus);
assert!(props.on_blur);
assert!(props.focus_style.is_some());
}
other => panic!("expected create, got {other:?}"),
}
}
#[test]
fn deserializes_transform_opacity_and_transition() {
let s: Style = serde_json::from_str(
r#"{
"transform": { "scale": 0.95, "translateX": 4, "translateY": "50%" },
"opacity": 0.5,
"transition": { "transform": { "duration": 0.15, "easing": "easeOut" } }
}"#,
)
.expect("style decodes");
let t = s.transform.expect("transform present");
assert_eq!(t.scale, Some(0.95));
assert_eq!(t.translate_x, Some(Length::Px(4.0)));
assert_eq!(t.translate_y, Some(Length::Percent(50.0)));
assert_eq!(t.scale_x, None);
assert_eq!(s.opacity, Some(0.5));
let transition = s.transition.expect("transition present");
assert!(transition.for_transform().is_some());
assert!(transition.for_opacity().is_none());
}
#[test]
fn angle_units() {
use std::f32::consts::{PI, TAU};
let parse = |v: serde_json::Value| serde_json::from_value::<Angle>(v).unwrap().radians();
assert!((parse(serde_json::json!(180)) - PI).abs() < 1e-5);
assert!((parse(serde_json::json!("180deg")) - PI).abs() < 1e-5);
assert!((parse(serde_json::json!("3.14159rad")) - PI).abs() < 1e-4);
assert!((parse(serde_json::json!("0.5turn")) - PI).abs() < 1e-5);
assert!((parse(serde_json::json!("400grad")) - TAU).abs() < 1e-5);
}
#[test]
fn border_color_scalar_and_per_side() {
let uniform: Style =
serde_json::from_str(r#"{ "borderColor": "white" }"#).expect("scalar decodes");
let bc = uniform.border_color.expect("border_color present");
assert_eq!(bc.top.as_deref(), Some("white"));
assert_eq!(bc.right.as_deref(), Some("white"));
assert_eq!(bc.bottom.as_deref(), Some("white"));
assert_eq!(bc.left.as_deref(), Some("white"));
let sided: Style =
serde_json::from_str(r##"{ "borderColor": { "top": "#f00", "left": "blue" } }"##)
.expect("object decodes");
let bc = sided.border_color.expect("border_color present");
assert_eq!(bc.top.as_deref(), Some("#f00"));
assert_eq!(bc.left.as_deref(), Some("blue"));
assert_eq!(bc.right, None);
assert_eq!(bc.bottom, None);
let bogus: Style =
serde_json::from_str(r#"{ "borderColor": { "middle": "red", "top": "blue" } }"#)
.expect("unknown side key must not abort deserialization");
let bc = bogus.border_color.expect("border_color present");
assert_eq!(bc.top.as_deref(), Some("blue"));
assert_eq!(bc.right, None);
assert_eq!(bc.bottom, None);
assert_eq!(bc.left, None);
}
#[test]
fn bad_unit_values_fall_back_instead_of_aborting() {
let s: Style = serde_json::from_str(r#"{ "width": "100pixels", "height": "40px" }"#)
.expect("a bad length must not abort deserialization");
assert_eq!(s.width, Some(Length::default()));
assert_eq!(s.height, Some(Length::Px(40.0)));
let s: Style = serde_json::from_str(r#"{ "fontSize": "16pxx" }"#)
.expect("bad fontSize must not abort");
assert_eq!(s.font_size, Some(FontSize::Px(0.0)));
let t: Transform = serde_json::from_str(r#"{ "rotate": "45degg", "translateX": "50%" }"#)
.expect("bad angle must not abort");
assert_eq!(t.rotate, Some(Angle::default()));
assert_eq!(t.translate_x, Some(Length::Percent(50.0)));
let s: Style =
serde_json::from_str(r#"{ "padding": "16asd" }"#).expect("bad rect must not abort");
assert_eq!(s.padding, Some(Rect::default()));
let s: Style = serde_json::from_str(r#"{ "padding": "8px 16asd" }"#)
.expect("partial-bad rect must not abort");
assert_eq!(
s.padding,
Some(Rect {
top: Length::Px(8.0),
bottom: Length::Px(8.0),
right: Length::default(),
left: Length::default(),
})
);
let s: Style = serde_json::from_str(r#"{ "padding": "8px 16px" }"#)
.expect("valid two-value shorthand decodes");
assert_eq!(
s.padding,
Some(Rect {
top: Length::Px(8.0),
bottom: Length::Px(8.0),
right: Length::Px(16.0),
left: Length::Px(16.0),
})
);
let s: Style = serde_json::from_str(r#"{ "padding": "1px 2px 3px 4px 5px" }"#)
.expect("bad value-count must not abort");
assert_eq!(s.padding, Some(Rect::default()));
}
#[test]
fn keyword_fields_decode_to_bevy_enums() {
let s: Style = serde_json::from_value(serde_json::json!({
"display": "grid",
"alignItems": "start",
"alignSelf": "flexStart",
"alignContent": "spaceBetween",
"justifyContent": "flexEnd",
"flexWrap": "nowrap",
"focusPolicy": "block",
"textAlign": "justify",
"lineBreak": "anyCharacter",
}))
.expect("keyword style decodes");
assert_eq!(s.display, Some(Display::Grid));
assert_eq!(s.align_items, Some(AlignItems::Start));
assert_eq!(s.align_self, Some(AlignSelf::FlexStart));
assert_eq!(s.align_content, Some(AlignContent::SpaceBetween));
assert_eq!(s.justify_content, Some(JustifyContent::FlexEnd));
assert_eq!(s.flex_wrap, Some(FlexWrap::NoWrap));
assert_eq!(s.focus_policy, Some(FocusPolicy::Block));
assert_eq!(s.text_align, Some(Justify::Justified));
assert_eq!(s.line_break, Some(LineBreak::AnyCharacter));
let s: Style = serde_json::from_value(serde_json::json!({
"alignItems": "flexStart",
"justifyContent": "start",
"boxSizing": "border-box",
"flexWrap": "noWrap",
}))
.expect("alias keywords decode");
assert_eq!(s.align_items, Some(AlignItems::FlexStart));
assert_eq!(s.justify_content, Some(JustifyContent::Start));
assert_eq!(s.box_sizing, Some(BoxSizing::BorderBox));
assert_eq!(s.flex_wrap, Some(FlexWrap::NoWrap));
}
#[test]
fn unknown_enum_keywords_fall_back_to_default() {
let s: Style = serde_json::from_value(serde_json::json!({
"display": "flx",
"alignItems": "centre",
"flexDirection": "sideways",
"textAlign": "middle",
"fontWeight": "heavyish",
"focusPolicy": "weird",
"lineBreak": "wordBoundary",
}))
.expect("bad keywords must not abort deserialization");
assert_eq!(s.display, Some(Display::default()));
assert_eq!(s.align_items, Some(AlignItems::default()));
assert_eq!(s.flex_direction, Some(FlexDirection::default()));
assert_eq!(s.text_align, Some(Justify::default()));
assert_eq!(s.font_weight, Some(FontWeight::NORMAL));
assert_eq!(s.focus_policy, Some(FocusPolicy::Pass));
assert_eq!(s.line_break, Some(LineBreak::WordBoundary));
}
#[test]
fn font_weight_keywords_and_numeric() {
let fw = |v: serde_json::Value| {
serde_json::from_value::<Style>(serde_json::json!({ "fontWeight": v }))
.expect("fontWeight decodes")
.font_weight
};
assert_eq!(fw("bold".into()), Some(FontWeight::BOLD));
assert_eq!(fw("600".into()), Some(FontWeight(600)));
assert_eq!(fw("thin".into()), Some(FontWeight::THIN));
}
#[test]
fn grid_templates_and_placement_decode() {
let s: Style = serde_json::from_value(serde_json::json!({
"gridTemplateColumns": "1fr 2fr 100px",
"gridTemplateRows": "repeat(3, 1fr)",
"gridAutoRows": "auto 40px",
}))
.expect("grid template decodes");
assert_eq!(s.grid_template_columns.map(|t| t.len()), Some(3));
assert_eq!(s.grid_template_rows.map(|t| t.len()), Some(1));
assert_eq!(s.grid_auto_rows.map(|t| t.len()), Some(2));
let s: Style =
serde_json::from_value(serde_json::json!({ "gridTemplateRows": "1fr bogus 2fr" }))
.expect("bad track must not abort");
assert_eq!(s.grid_template_rows.map(|t| t.len()), Some(2));
let placed = |v: &str| {
let s: Style = serde_json::from_value(serde_json::json!({ "gridRow": v }))
.expect("grid placement decodes");
format!("{:?}", s.grid_row.unwrap())
};
let expect = |p: GridPlacement| format!("{p:?}");
assert_eq!(placed("1 / 3"), expect(GridPlacement::start_end(1, 3)));
assert_eq!(placed("span 2"), expect(GridPlacement::span(2)));
assert_eq!(
placed("2 / span 3"),
expect(GridPlacement::start_span(2, 3))
);
assert_eq!(placed("2 / 2"), expect(GridPlacement::start_end(2, 2)));
assert_eq!(placed("-1"), expect(GridPlacement::start(-1)));
assert_eq!(placed("2 / auto"), expect(GridPlacement::start(2)));
assert_eq!(placed("auto / 3"), expect(GridPlacement::end(3)));
}
#[test]
fn grid_placement_zero_falls_back_to_auto() {
let placed = |v: &str| {
let s: Style = serde_json::from_value(serde_json::json!({ "gridRow": v }))
.expect("zero placement must not abort");
format!("{:?}", s.grid_row.unwrap())
};
let auto = format!("{:?}", GridPlacement::auto());
for s in ["0", "span 0", "0 / 2", "2 / 0", "0 / span 2", "2 / span 0"] {
assert_eq!(placed(s), auto, "input {s:?}");
}
assert_eq!(placed("garbage"), auto);
}
#[test]
fn deserializes_filter_functions() {
let s: Style = serde_json::from_str(
r#"{ "filter": {
"blur": "4px", "brightness": 1.2, "grayscale": 1,
"saturate": 0.5, "hueRotate": 90
} }"#,
)
.expect("filter decodes");
let f = s.filter.expect("filter present");
assert_eq!(f.blur, Some(Length::Px(4.0)));
assert_eq!(f.brightness, Some(1.2));
assert_eq!(f.grayscale, Some(1.0));
assert_eq!(f.saturate, Some(0.5));
assert!((f.hue_rotate.unwrap().radians() - std::f32::consts::FRAC_PI_2).abs() < 1e-5);
assert_eq!(f.contrast, None);
assert_eq!(f.sepia, None);
assert_eq!(f.invert, None);
let s: Style = serde_json::from_str(r#"{ "filter": { "blur": "4pxx" }, "opacity": 0.5 }"#)
.expect("a bad filter unit must not abort the style");
assert_eq!(s.filter.unwrap().blur, Some(Length::default()));
assert_eq!(s.opacity, Some(0.5));
}
#[test]
fn serializes_change_event_with_value() {
let ev = UiEvent {
id: 7,
kind: "change".into(),
value: Some("hello".into()),
..Default::default()
};
let v = serde_json::to_value(&ev).expect("serializable");
assert_eq!(v["kind"], "change");
assert_eq!(v["value"], "hello");
assert!(v.get("clientX").is_none(), "pointer fields omitted");
assert!(v.get("button").is_none(), "button omitted on text events");
}
#[test]
fn serializes_pointer_event_with_button() {
let ev = UiEvent {
id: 3,
kind: "pointerDown".into(),
button: Some(2),
..Default::default()
};
let v = serde_json::to_value(&ev).expect("serializable");
assert_eq!(v["kind"], "pointerDown");
assert_eq!(v["button"], 2);
}
#[test]
fn style_field_table_is_complete() {
macro_rules! build_full {
($(($f:ident, $name:literal, $g:tt, $ov:ident),)*) => {
Style { $($f: None,)* }
};
}
let _style: Style = with_style_fields!(build_full);
}
#[test]
fn style_wire_names_match_serde_rename() {
fn camel(s: &str) -> String {
let mut out = String::new();
let mut up = false;
for c in s.chars() {
if c == '_' {
up = true;
} else if up {
out.extend(c.to_uppercase());
up = false;
} else {
out.push(c);
}
}
out
}
macro_rules! check {
($(($f:ident, $name:literal, $g:tt, $ov:ident),)*) => {
$( assert_eq!(camel(stringify!($f)), $name, "table wire name for `{}`", stringify!($f)); )*
};
}
with_style_fields!(check);
}
fn props(json: serde_json::Value) -> Props {
serde_json::from_value(json).expect("valid props")
}
/// A delta sets exactly the supplied fields; everything else is preserved.
#[test]
fn merge_delta_sets_and_preserves() {
let mut cached = props(serde_json::json!({
"style": { "backgroundColor": "red", "outline": { "color": "white" } },
"hoverStyle": { "backgroundColor": "blue" },
"onClick": true,
"src": "a.png",
}));
let (dirty, ev) = cached.merge_delta(
props(serde_json::json!({ "style": { "width": 100 } })),
&[],
&[],
);
let style = cached.style.as_ref().unwrap();
assert_eq!(style.width, Some(Length::Px(100.0)));
assert_eq!(style.background_color.as_deref(), Some("red"));
assert!(style.outline.is_some(), "untouched style fields preserved");
assert!(cached.hover_style.is_some(), "untouched props preserved");
assert!(cached.on_click);
assert_eq!(cached.src.as_deref(), Some("a.png"));
assert!(dirty.style.intersects(style_groups::LAYOUT));
assert!(
!dirty
.style
.intersects(style_groups::BACKGROUND | style_groups::OUTLINE),
"untouched groups must stay clean"
);
assert!(!dirty.hover_style && !dirty.pointer && !dirty.image);
// `width` is a transitioned channel, so the transition group re-arms.
assert!(dirty.style.intersects(style_groups::TRANSITION));
assert!(ev.value.is_none() && ev.draw.is_none());
}
/// `unset` resets props (bools to false, options to None); `style_unset`
/// clears style fields — even when the delta carries no `style` object.
#[test]
fn merge_delta_unsets() {
let mut cached = props(serde_json::json!({
"style": { "backgroundColor": "red", "width": 50 },
"hoverStyle": { "backgroundColor": "blue" },
"onClick": true,
}));
let (dirty, _) = cached.merge_delta(
Props::default(),
&["hoverStyle".into(), "onClick".into()],
&["backgroundColor".into()],
);
let style = cached.style.as_ref().unwrap();
assert_eq!(style.background_color, None);
assert_eq!(
style.width,
Some(Length::Px(50.0)),
"other style fields kept"
);
assert!(cached.hover_style.is_none());
assert!(!cached.on_click);
assert!(dirty.style.intersects(style_groups::BACKGROUND));
assert!(!dirty.style.intersects(style_groups::LAYOUT));
assert!(dirty.hover_style && dirty.pointer);
assert!(dirty.any_style_variant());
}
/// `"style"` in `unset` drops the whole style and dirties every group.
#[test]
fn merge_delta_unsets_style_wholesale() {
let mut cached = props(serde_json::json!({
"style": { "backgroundColor": "red", "width": 50 },
}));
let (dirty, _) = cached.merge_delta(Props::default(), &["style".into()], &[]);
assert!(cached.style.is_none());
assert_eq!(dirty.style, StyleDirty::ALL);
}
/// Event-like fields ride out through `UpdateEvents` and are never retained.
#[test]
fn merge_delta_events_not_cached() {
let mut cached = Props::default();
let (dirty, ev) = cached.merge_delta(
props(serde_json::json!({
"value": "hi", "selectionStart": 1, "selectionEnd": 3,
"scrollTop": 40.0, "scrollLeft": 2.0,
})),
&[],
&[],
);
assert_eq!(ev.value.as_deref(), Some("hi"));
assert_eq!((ev.selection_start, ev.selection_end), (Some(1), Some(3)));
assert_eq!((ev.scroll_top, ev.scroll_left), (Some(40.0), Some(2.0)));
assert!(cached.value.is_none() && cached.scroll_top.is_none());
assert!(cached.selection_start.is_none());
// Event fields alone dirty nothing.
assert!(!dirty.style.any() && !dirty.image && !dirty.anchor);
}
/// Variant styles replace atomically: a delta `hoverStyle` is the whole new
/// value, not a merge into the previous one.
#[test]
fn merge_delta_replaces_variants_atomically() {
let mut cached = props(serde_json::json!({
"hoverStyle": { "backgroundColor": "blue", "width": 10 },
}));
let (dirty, _) = cached.merge_delta(
props(serde_json::json!({ "hoverStyle": { "outline": { "color": "white" } } })),
&[],
&[],
);
let hover = cached.hover_style.as_ref().unwrap();
assert!(hover.outline.is_some());
assert_eq!(hover.background_color, None, "atomic replace, not a merge");
assert_eq!(hover.width, None);
assert!(dirty.hover_style);
}
/// Unknown names in `unset`/`style_unset` warn and are ignored — a delta
/// from a newer/older bundle must never panic the op drain.
#[test]
fn merge_delta_ignores_unknown_names() {
let mut cached = props(serde_json::json!({ "style": { "width": 10 } }));
let (dirty, _) = cached.merge_delta(
Props::default(),
&["nope".into(), "value".into()],
&["alsoNope".into()],
);
assert_eq!(cached.style.as_ref().unwrap().width, Some(Length::Px(10.0)));
assert!(!dirty.style.any());
}
/// Two sequential deltas converge to the same state as one combined delta.
#[test]
fn merge_delta_converges() {
let base = serde_json::json!({
"style": { "backgroundColor": "red", "width": 10 }, "onClick": true,
});
let mut two_steps = props(base.clone());
two_steps.merge_delta(
props(serde_json::json!({ "style": { "width": 20 } })),
&[],
&[],
);
two_steps.merge_delta(
props(serde_json::json!({ "style": { "height": 5 } })),
&[],
&["backgroundColor".into()],
);
let mut one_step = props(base);
one_step.merge_delta(
props(serde_json::json!({ "style": { "width": 20, "height": 5 } })),
&[],
&["backgroundColor".into()],
);
let a = two_steps.style.as_ref().unwrap();
let b = one_step.style.as_ref().unwrap();
assert_eq!(a.width, b.width);
assert_eq!(a.height, b.height);
assert_eq!(a.background_color, b.background_color);
assert!(two_steps.on_click && one_step.on_click);
}
/// `split_events` strips exactly the event-like fields, leaving state.
#[test]
fn split_events_strips_event_fields() {
let full = props(serde_json::json!({
"style": { "width": 10 }, "onClick": true, "value": "v",
"selectionStart": 0, "selectionEnd": 1, "scrollTop": 5.0,
}));
let (state, ev) = full.split_events();
assert!(state.style.is_some() && state.on_click);
assert!(state.value.is_none() && state.selection_start.is_none());
assert!(state.scroll_top.is_none());
assert_eq!(ev.value.as_deref(), Some("v"));
assert_eq!(ev.scroll_top, Some(5.0));
}
/// An `update` op decodes with and without the unset lists — `styleUnset`
/// in particular must land in `style_unset` (the enum's `rename_all`
/// doesn't cover variant fields).
#[test]
fn deserializes_update_delta_form() {
let minimal: Op = serde_json::from_str(r#"{"op":"update","id":3,"props":{}}"#).unwrap();
match minimal {
Op::Update {
unset, style_unset, ..
} => {
assert!(unset.is_empty() && style_unset.is_empty());
}
other => panic!("expected update, got {other:?}"),
}
let full: Op = serde_json::from_str(
r#"{"op":"update","id":3,"props":{"style":{"width":1}},
"unset":["onClick"],"styleUnset":["backgroundColor"]}"#,
)
.unwrap();
match full {
Op::Update {
unset, style_unset, ..
} => {
assert_eq!(unset, vec!["onClick"]);
assert_eq!(style_unset, vec!["backgroundColor"]);
}
other => panic!("expected update, got {other:?}"),
}
}
/// A `draw` op decodes, including the clear commands (the imperative
/// canvas path). Struct-variant fields aren't renamed by the enum's
/// `rename_all`, so the wire form is pinned here.
#[test]
fn deserializes_draw_op() {
let op: Op = serde_json::from_str(
r##"{"op":"draw","id":7,"cmds":[
{"cmd":"clear"},
{"cmd":"clearRect","x":1.0,"y":2.0,"w":3.0,"h":4.0},
{"cmd":"fillStyle","color":"#f00"}
]}"##,
)
.unwrap();
match op {
Op::Draw { id, cmds } => {
assert_eq!(id, 7);
assert_eq!(cmds.len(), 3);
assert_eq!(cmds[0], DrawCmd::Clear);
assert_eq!(
cmds[1],
DrawCmd::ClearRect {
x: 1.0,
y: 2.0,
w: 3.0,
h: 4.0
}
);
assert_eq!(
cmds[2],
DrawCmd::FillStyle {
color: "#f00".into()
}
);
}
other => panic!("expected draw, got {other:?}"),
}
}
/// A `"resize"` UI event serializes its logical size and omits every other
/// optional field.
#[test]
fn serializes_resize_ui_event() {
let v = serde_json::to_value(Outbound::UiEvent {
event: UiEvent {
id: 5,
kind: "resize".into(),
width: Some(300.0),
height: Some(150.0),
..Default::default()
},
})
.unwrap();
assert_eq!(v["t"], "uiEvent");
let ev = &v["event"];
assert_eq!(ev["id"], 5);
assert_eq!(ev["kind"], "resize");
assert_eq!(ev["width"], 300.0);
assert_eq!(ev["height"], 150.0);
assert!(ev.get("x").is_none() && ev.get("scrollTop").is_none());
}
/// `onResize` decodes, merges into the cache, and unsets without warning —
/// it gates nothing Rust-side, so it dirties nothing.
#[test]
fn merge_delta_on_resize_flag() {
let mut cached = Props::default();
let (dirty, _) =
cached.merge_delta(props(serde_json::json!({ "onResize": true })), &[], &[]);
assert!(cached.on_resize);
assert!(!dirty.pointer && !dirty.scroll_listener);
cached.merge_delta(Props::default(), &["onResize".into()], &[]);
assert!(!cached.on_resize);
}
/// `onWheel` sets the `wheel` dirty flag on appearance and clears it on `unset`,
/// independent of the scroll flags.
#[test]
fn merge_delta_wheel_flag() {
let mut cached = Props::default();
let (dirty, _) =
cached.merge_delta(props(serde_json::json!({ "onWheel": true })), &[], &[]);
assert!(cached.on_wheel);
assert!(dirty.wheel);
assert!(!dirty.pointer && !dirty.scroll_listener);
let (dirty, _) = cached.merge_delta(Props::default(), &["onWheel".into()], &[]);
assert!(!cached.on_wheel);
assert!(dirty.wheel);
}
/// `cursor` decodes to the raw name (keyword or custom); resolution (registry
/// first, then system keyword) is deferred to `drive_cursor_icon`, like `fontFamily`.
#[test]
fn deserializes_cursor_name() {
let s: Style = serde_json::from_str(r#"{ "cursor": "pointer" }"#).expect("cursor decodes");
assert_eq!(s.cursor.as_deref(), Some("pointer"));
let s: Style =
serde_json::from_str(r#"{ "cursor": "hand" }"#).expect("custom name decodes");
assert_eq!(s.cursor.as_deref(), Some("hand"));
}
/// A `cursor` delta sets the `CURSOR` dirty group; a `style` unset of it clears
/// the field and re-arms the group.
#[test]
fn merge_delta_cursor_group() {
let mut cached = Props::default();
let (dirty, _) = cached.merge_delta(
props(serde_json::json!({ "style": { "cursor": "pointer" } })),
&[],
&[],
);
assert_eq!(
cached.style.as_ref().unwrap().cursor.as_deref(),
Some("pointer")
);
assert!(dirty.style.intersects(style_groups::CURSOR));
assert!(!dirty.style.intersects(style_groups::LAYOUT));
let (dirty, _) = cached.merge_delta(Props::default(), &[], &["cursor".into()]);
assert_eq!(cached.style.as_ref().unwrap().cursor, None);
assert!(dirty.style.intersects(style_groups::CURSOR));
}
}