bevy_react/protocol/style.rs
1//! The [`Style`] object, its dirty-group partition ([`style_groups`],
2//! [`StyleDirty`]), and the `with_style_fields!` table every style field is
3//! registered in.
4
5use serde::Deserialize;
6
7use bevy::text::{FontWeight, Justify, LineBreak};
8use bevy::ui::{
9 AlignContent, AlignItems, AlignSelf, BoxSizing, Display, FlexDirection, FlexWrap, FocusPolicy,
10 GridAutoFlow, GridPlacement, GridTrack, JustifyContent, JustifyItems, JustifySelf,
11 OverflowAxis, PositionType, RepeatedGridTrack,
12};
13
14use super::animatable::Animatable;
15use super::background_image::{BackgroundImageSpec, de_background_image};
16use super::grid::{de_grid_auto_tracks, de_grid_placement, de_grid_template};
17use super::keywords::*;
18use super::transform::{Transform, Transform3d};
19use super::units::{FontSize, Length, Rect};
20use super::visual::{
21 BorderColorSpec, BoxShadowList, GradientList, LetterSpacingSpec, LineHeightSpec, OutlineSpec,
22 TextShadowSpec,
23};
24
25/// The [`Style::cache`] keyword: `"auto"` (default) leaves promotion to the
26/// other rules; `"always"` force-promotes the subtree to a cached composited
27/// layer; `"never"` force-promotes it too but re-captures it **every frame** —
28/// the escape hatch for content whose pixels are written outside the dirt
29/// tracking's sight (a live `<portal>` render target, an app-owned texture).
30/// Opting out of *opacity* promotion is `groupAlpha: false`.
31#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
32pub enum LayerCache {
33 #[default]
34 Auto,
35 Always,
36 Never,
37}
38
39/// A CSS-like style object mapped onto `bevy_ui::Node` and its sibling visual
40/// components. Every field is optional; unset fields keep Bevy's defaults.
41///
42/// Length-valued fields accept a bare number (logical pixels) or a unit string
43/// (`"50%"`, `"100vw"`, `"auto"`, `"10px"`). Rect-valued fields
44/// (`margin`/`padding`/`border`/`borderRadius`) accept a number (uniform), a CSS
45/// shorthand string (`"8px 16px"`), or a `{ top, right, bottom, left }` object.
46/// Keyword-valued fields (`display`, `align*`, `flex*`, …) decode straight into
47/// the `bevy_ui`/`bevy_text` enum they drive (see the `keyword_fields!`
48/// deserializers below); an unrecognized keyword warns and falls back to the
49/// bevy default. Grid tracks/placements likewise parse once at decode.
50#[derive(Debug, Clone, Default, Deserialize)]
51#[serde(rename_all = "camelCase")]
52pub struct Style {
53 // --- display / box model ---
54 #[serde(default, deserialize_with = "de_display")]
55 pub display: Option<Display>,
56 #[serde(default, deserialize_with = "de_box_sizing")]
57 pub box_sizing: Option<BoxSizing>,
58 #[serde(default, deserialize_with = "de_position_type")]
59 pub position_type: Option<PositionType>,
60 #[serde(default, deserialize_with = "de_overflow_axis")]
61 pub overflow_x: Option<OverflowAxis>,
62 #[serde(default, deserialize_with = "de_overflow_axis")]
63 pub overflow_y: Option<OverflowAxis>,
64 #[serde(default)]
65 pub scrollbar_width: Option<f32>,
66
67 // --- inset ---
68 #[serde(default)]
69 pub left: Option<Animatable<Length>>,
70 #[serde(default)]
71 pub right: Option<Animatable<Length>>,
72 #[serde(default)]
73 pub top: Option<Animatable<Length>>,
74 #[serde(default)]
75 pub bottom: Option<Animatable<Length>>,
76
77 // --- size ---
78 #[serde(default)]
79 pub width: Option<Animatable<Length>>,
80 #[serde(default)]
81 pub height: Option<Animatable<Length>>,
82 #[serde(default)]
83 pub min_width: Option<Animatable<Length>>,
84 #[serde(default)]
85 pub min_height: Option<Animatable<Length>>,
86 #[serde(default)]
87 pub max_width: Option<Animatable<Length>>,
88 #[serde(default)]
89 pub max_height: Option<Animatable<Length>>,
90 #[serde(default)]
91 pub aspect_ratio: Option<Animatable<f32>>,
92
93 // --- alignment ---
94 #[serde(default, deserialize_with = "de_align_items")]
95 pub align_items: Option<AlignItems>,
96 #[serde(default, deserialize_with = "de_justify_items")]
97 pub justify_items: Option<JustifyItems>,
98 #[serde(default, deserialize_with = "de_align_self")]
99 pub align_self: Option<AlignSelf>,
100 #[serde(default, deserialize_with = "de_justify_self")]
101 pub justify_self: Option<JustifySelf>,
102 #[serde(default, deserialize_with = "de_align_content")]
103 pub align_content: Option<AlignContent>,
104 #[serde(default, deserialize_with = "de_justify_content")]
105 pub justify_content: Option<JustifyContent>,
106
107 // --- spacing ---
108 #[serde(default)]
109 pub margin: Option<Rect>,
110 #[serde(default)]
111 pub padding: Option<Rect>,
112 #[serde(default)]
113 pub border: Option<Rect>,
114
115 // --- flex ---
116 #[serde(default, deserialize_with = "de_flex_direction")]
117 pub flex_direction: Option<FlexDirection>,
118 #[serde(default, deserialize_with = "de_flex_wrap")]
119 pub flex_wrap: Option<FlexWrap>,
120 #[serde(default)]
121 pub flex_grow: Option<f32>,
122 #[serde(default)]
123 pub flex_shrink: Option<f32>,
124 #[serde(default)]
125 pub flex_basis: Option<Animatable<Length>>,
126 #[serde(default)]
127 pub gap: Option<Animatable<Length>>,
128 #[serde(default)]
129 pub row_gap: Option<Animatable<Length>>,
130 #[serde(default)]
131 pub column_gap: Option<Animatable<Length>>,
132
133 // --- grid ---
134 #[serde(default, deserialize_with = "de_grid_auto_flow")]
135 pub grid_auto_flow: Option<GridAutoFlow>,
136 /// CSS grid template (`"repeat(3, 1fr)"`, `"1fr 2fr 100px"`, `"auto"`).
137 #[serde(default, deserialize_with = "de_grid_template")]
138 pub grid_template_rows: Option<Vec<RepeatedGridTrack>>,
139 #[serde(default, deserialize_with = "de_grid_template")]
140 pub grid_template_columns: Option<Vec<RepeatedGridTrack>>,
141 /// Auto-track sizing (`grid-auto-rows`/`columns`); no `repeat()`.
142 #[serde(default, deserialize_with = "de_grid_auto_tracks")]
143 pub grid_auto_rows: Option<Vec<GridTrack>>,
144 #[serde(default, deserialize_with = "de_grid_auto_tracks")]
145 pub grid_auto_columns: Option<Vec<GridTrack>>,
146 /// Grid line placement (`"1 / 3"`, `"span 2"`, `"2"`, `"auto"`).
147 #[serde(default, deserialize_with = "de_grid_placement")]
148 pub grid_row: Option<GridPlacement>,
149 #[serde(default, deserialize_with = "de_grid_placement")]
150 pub grid_column: Option<GridPlacement>,
151
152 // --- visual (sibling components) ---
153 /// Hex background color (`#rrggbb` / `#rrggbbaa`). Animated via an
154 /// `interpolateColor` binding (`{ animated: … }`).
155 #[serde(default)]
156 pub background_color: Option<Animatable<String>>,
157 /// Border color: a single CSS color (all four sides) or a
158 /// `{ top, right, bottom, left }` object (omitted sides → transparent).
159 /// Only the single-color form is animatable (the binding drives all four
160 /// sides); per-side `{ animated }` wrappers warn and are ignored.
161 #[serde(default)]
162 pub border_color: Option<Animatable<BorderColorSpec>>,
163 /// Corner radii; same forms as the other rect fields (corners are
164 /// top-left, top-right, bottom-right, bottom-left).
165 #[serde(default)]
166 pub border_radius: Option<Rect>,
167 #[serde(default)]
168 pub outline: Option<OutlineSpec>,
169 #[serde(default)]
170 pub box_shadow: Option<BoxShadowList>,
171 /// Layer-based, subtree-wide `filter` chain (see [`crate::filters`]): one
172 /// `{ name, params }` object (a 1-element chain) or an ordered array of
173 /// them (chain order = pass order). Omitted params take the filter's
174 /// CSS-shorthand default (a bare `grayscale` is *full* grayscale, while
175 /// `brightness`/`contrast`/`saturate` default to identity). `params`
176 /// stays an untyped map at decode; it is validated later against the
177 /// registered filters
178 /// ([`FilterRegistry`](crate::filters::FilterRegistry)). A non-empty
179 /// chain promotes the node to a composited layer (see [`crate::layer`]);
180 /// hover/press/focus variants carry the field too (with a
181 /// [`transition`](Self::transition) the swap eases — see
182 /// `crate::filters`). A chain carried *only* by a variant still promotes
183 /// eagerly at mount — promotion is presence-based across the base style
184 /// and every variant, so the layer exists before the first hover.
185 #[serde(default)]
186 pub filter: Option<crate::filters::FilterChain>,
187 /// Layer-based `backdropFilter` chain — same wire shape as
188 /// [`filter`](Self::filter) (one `{ name, params }` or an ordered array,
189 /// validated against the same registry), but it filters what is rendered
190 /// *behind* the node (v1: the camera's post-processed 3D frame — no UI)
191 /// and draws the result as an opaque quad under the node's own content.
192 /// A non-empty chain promotes (presence union across base + variants,
193 /// like `filter`). Unsetting it demotes, so an eased removal needs an
194 /// identity entry left in the base chain (same snap rule as `filter`).
195 #[serde(default)]
196 pub backdrop_filter: Option<crate::filters::FilterChain>,
197 /// Background gradient(s); one gradient or a layered list. bevy paints it
198 /// *over* `backgroundColor` (CSS `background-image` semantics): an opaque
199 /// gradient hides the color (fallback); transparent stops reveal it.
200 #[serde(default)]
201 pub background_gradient: Option<GradientList>,
202 /// Border gradient(s); one gradient or a layered list. Painted *over*
203 /// `borderColor` (needs a `border` width to be visible).
204 #[serde(default)]
205 pub border_gradient: Option<GradientList>,
206 /// Background image: painted *over* `backgroundColor` **and**
207 /// `backgroundGradient`, under the node's content (bevy's fixed per-node
208 /// paint order). `src` is an asset path, or `{ texture }` naming a render
209 /// target registered in `crate::portal::RenderTargets`. Never affects
210 /// layout (the layout-driving `Auto` image mode is never emitted).
211 /// Ignored — with a devtools warning — on `image`/`canvas`/`portal`
212 /// (their `ImageNode` belongs to the element) and `surface`.
213 #[serde(default, deserialize_with = "de_background_image")]
214 pub background_image: Option<BackgroundImageSpec>,
215 #[serde(default)]
216 pub z_index: Option<i32>,
217 /// Global stacking order: lifts the node (and its subtree) into the UI's
218 /// top-level stack, escaping the parent stacking context. Unlike [`z_index`](Self::z_index),
219 /// which only reorders a node among its siblings.
220 #[serde(default)]
221 pub global_z_index: Option<i32>,
222 /// Pointer pass-through. Maps to `bevy::ui::FocusPolicy`. `"pass"` lets pointer
223 /// interaction fall through to nodes behind this one; `"block"` makes it
224 /// *capture* interaction so siblings, the 3D scene, and portals behind it don't
225 /// receive it. When unset the default is element-dependent (set in the
226 /// reconciler): a `<button>` blocks, a `<node>`/container passes.
227 #[serde(default, deserialize_with = "de_focus_policy")]
228 pub focus_policy: Option<FocusPolicy>,
229 /// Mouse cursor shown while the pointer is over this node (CSS `cursor`).
230 /// A system keyword (winit's `SystemCursorIcon`) or a custom-cursor name
231 /// registered via `ReactUiPlugin::cursor`; the name is resolved (registry first,
232 /// so a custom cursor can override a system keyword) onto the window's
233 /// `CursorIcon` by `crate::cursor::drive_cursor_icon`. Like `font_family`, a raw
234 /// name resolved at drive time. Absent → the node contributes no cursor (its
235 /// ancestor's or the default arrow shows).
236 #[serde(default)]
237 pub cursor: Option<String>,
238
239 // --- transform / opacity (drive `UiTransform` and color alpha) ---
240 /// Static transform (translate/scale/rotate). Mirrors the animated transform
241 /// channels; written to `UiTransform`. With a [`transition`](Self::transition)
242 /// a change eases instead of snapping.
243 #[serde(default)]
244 pub transform: Option<Transform>,
245 /// 3D perspective transform, applied to the subtree's *rendered result* at
246 /// composite time (group semantics, like `opacity`/`filter`). Presence —
247 /// even an identity `{}` — promotes the subtree to a composited layer (see
248 /// [`crate::layer`]); the captured texture is drawn as one quad through the
249 /// matrix, so animating it never re-captures. Unlike `transform` (which
250 /// stays main-world and bakes into the capture), this never touches layout,
251 /// and ancestor clips clamp the transformed result. With a
252 /// [`transition`](Self::transition) a change eases field-wise.
253 #[serde(default)]
254 pub transform3d: Option<Transform3d>,
255 /// Opacity in `0.0..=1.0`, multiplied into the alpha of the background (and
256 /// text) color. With a [`transition`](Self::transition) a change eases.
257 /// On a node with children (unless [`group_alpha`](Self::group_alpha) is
258 /// `false`) the subtree is instead promoted to a composited layer and the
259 /// value applies once to the whole group — see [`crate::layer`].
260 #[serde(default)]
261 pub opacity: Option<Animatable<f32>>,
262 /// Whether `opacity` on a node with children fades the subtree as a group
263 /// (composited layer) rather than folding into each node's own colors.
264 /// Default `true` (web semantics); `false` opts out of layer promotion for
265 /// perf-sensitive spots, keeping the per-node fold. `no_overlay`: a hover/
266 /// press variant must not be able to flip promotion.
267 #[serde(default)]
268 pub group_alpha: Option<bool>,
269 /// Layer-cache hint. `"always"` force-promotes the subtree to a composited
270 /// layer (see [`crate::layer`]) so its capture is cached and re-rendered
271 /// only when its content changes — the `will-change` pattern for static
272 /// or transform/opacity-animated subtrees. `"never"` also force-promotes,
273 /// but the capture re-runs **every frame** — for content written outside
274 /// the dirt tracking's sight (live `<portal>` targets, app-owned textures).
275 /// `"auto"` (or absent, the default) promotes only when another rule does
276 /// (today: `opacity`). `no_overlay`: a variant must not flip promotion.
277 #[serde(default, deserialize_with = "de_layer_cache")]
278 pub cache: Option<LayerCache>,
279 /// CSS-like per-channel transition timing. Present → a change to `transform` /
280 /// `opacity` / `backgroundColor` (via re-render or hover/press) animates over
281 /// time using the same driver/easing engine as `{ animated }` bindings, rather than
282 /// snapping. See [`crate::transition`].
283 #[serde(default)]
284 pub transition: Option<crate::transition::Transition>,
285
286 /// Visible scrollbar for an `overflow: scroll` node: `"none"` (default) /
287 /// `"default"` / a styled object. Present → the reconciler stamps a
288 /// [`crate::scrollbar::ScrollbarConfig`] and the shell spawns Bevy's headless
289 /// scrollbar widget over the container. Pure-serde, module-owned.
290 #[serde(default)]
291 pub scrollbar: Option<crate::scrollbar::ScrollbarSpec>,
292
293 // --- text (only meaningful on `<text>` elements/spans) ---
294 /// Hex text color. Animated via an `interpolateColor` binding.
295 #[serde(default)]
296 pub color: Option<Animatable<String>>,
297 /// Font size: a number (logical pixels) or a unit string (`"24px"`, `"2vw"`,
298 /// `"1.5rem"`). See [`FontSize`].
299 #[serde(default)]
300 pub font_size: Option<FontSize>,
301 /// `"thin" | "light" | "normal" | "medium" | "semibold" | "bold" | "black"`
302 /// or a numeric weight string (e.g. `"600"`).
303 #[serde(default, deserialize_with = "de_font_weight")]
304 pub font_weight: Option<FontWeight>,
305 /// Registered font-family name to render this text with (see the plugin's
306 /// `default_font`/`font` config). Unknown or unset → the configured default
307 /// font.
308 #[serde(default)]
309 pub font_family: Option<String>,
310 /// Horizontal alignment of the text block (`<text>` root only):
311 /// `"left" | "center" | "right" | "justify" | "start" | "end"`.
312 #[serde(default, deserialize_with = "de_text_align")]
313 pub text_align: Option<Justify>,
314 /// Line height. A bare number is a multiple of the font size; `{ "px": n }`
315 /// is an absolute pixel height. Unset → bevy's default (1.2× the font size).
316 #[serde(default)]
317 pub line_height: Option<LineHeightSpec>,
318 /// Letter spacing. A bare number is logical pixels; `{ "rem": n }` is a
319 /// multiple of the font size. Unset → no extra spacing.
320 #[serde(default)]
321 pub letter_spacing: Option<LetterSpacingSpec>,
322 /// A single drop shadow behind the text (`<text>` root only).
323 #[serde(default)]
324 pub text_shadow: Option<TextShadowSpec>,
325 /// How the text wraps when it overflows its bounds (`<text>` root only):
326 /// `"wordBoundary"` (default) | `"anyCharacter"` | `"wordOrCharacter"` |
327 /// `"noWrap"`.
328 #[serde(default, deserialize_with = "de_line_break")]
329 pub line_break: Option<LineBreak>,
330}
331
332/// Bit flags naming the groups of work [`crate::ui_map::apply_style`] (and the
333/// update reconciler) derive from a [`Style`]. Each [`Style`] field belongs to
334/// the group(s) whose output reads it (see [`with_style_fields`]); a delta
335/// update ORs the groups of its touched fields into a [`StyleDirty`] mask so
336/// the apply path can skip every group the delta provably didn't affect.
337pub mod style_groups {
338 /// `bevy_ui::Node` (`node_from_style`): every layout field.
339 pub const LAYOUT: u32 = 1 << 0;
340 /// `BackgroundColor` (reads `background_color`, `opacity`).
341 pub const BACKGROUND: u32 = 1 << 1;
342 /// `UiTransform` (reads `transform`).
343 pub const TRANSFORM: u32 = 1 << 2;
344 /// `BorderColor`.
345 pub const BORDER_COLOR: u32 = 1 << 3;
346 /// `Outline`.
347 pub const OUTLINE: u32 = 1 << 4;
348 /// `BoxShadow`.
349 pub const BOX_SHADOW: u32 = 1 << 5;
350 /// `BackgroundGradient` (reads `background_gradient`, `opacity`).
351 pub const BG_GRADIENT: u32 = 1 << 6;
352 /// `BorderGradient` (reads `border_gradient`, `opacity`).
353 pub const BORDER_GRADIENT: u32 = 1 << 7;
354 /// `TextShadow` (reads `text_shadow`, `opacity`).
355 pub const TEXT_SHADOW: u32 = 1 << 8;
356 /// `ZIndex`.
357 pub const Z_INDEX: u32 = 1 << 9;
358 /// `GlobalZIndex`.
359 pub const GLOBAL_Z_INDEX: u32 = 1 << 10;
360 /// `FocusPolicy` (also `apply_button_focus_default` in the reconciler).
361 pub const FOCUS_POLICY: u32 = 1 << 11;
362 /// The wire `filter` chain → `FilterInput` (the chain resolver's *and*
363 /// the transition filter channel's target; see `crate::filters`).
364 pub const FILTER: u32 = 1 << 12;
365 /// `TransitionInput` (`TransitionInput::from_style` reads `transition` plus
366 /// every transitioned channel: `transform`, `opacity`, `background_color`,
367 /// `width`, `height`, `max_width`, `max_height`). The filter channel's
368 /// timing rides the spec here; its *target* is `FilterInput` (FILTER).
369 pub const TRANSITION: u32 = 1 << 13;
370 /// `ScrollTransitionInput` (reads `transition`).
371 pub const SCROLL_TRANSITION: u32 = 1 << 14;
372 /// The resolved text style (`resolved_text_style`: `color`, `font_size`,
373 /// `font_weight`, `font_family`, `line_height`, `letter_spacing`,
374 /// `opacity`) — includes the `<text>` re-propagation to inheriting spans.
375 pub const TEXT: u32 = 1 << 15;
376 /// `TextLayout` (`text_layout`: `text_align`, `line_break`).
377 pub const TEXT_LAYOUT: u32 = 1 << 16;
378 /// `NodeCursor` (reads `cursor`) — the per-node cursor `drive_cursor_icon`
379 /// writes onto the window's `CursorIcon` on hover.
380 pub const CURSOR: u32 = 1 << 17;
381 /// `ScrollbarConfig` (reads `scrollbar`) — the visible scrollbar shell
382 /// (`crate::scrollbar`) spawns/updates Bevy's scrollbar widget from it. The
383 /// field is *also* in `LAYOUT` because a gutter-positioned bar drives
384 /// `Node.scrollbar_width` (see `node_from_style`).
385 pub const SCROLLBAR: u32 = 1 << 18;
386 /// Layer-promotion inputs (`crate::layer`): fields that change whether a
387 /// subtree composites as a layer (`opacity`, `group_alpha`, `cache`,
388 /// `filter`, `transform3d`). No `apply_style` output reads this group — it
389 /// exists so a delta touching a promotion trigger is visible to the
390 /// promotion evaluator.
391 pub const LAYER: u32 = 1 << 19;
392 /// `LayerTransform3d` (reads `transform3d`) — the composite-time 3D
393 /// transform on a promoted layer (`crate::layer::transform3d`). Never
394 /// content dirt: matrix changes reshape the composite quad only.
395 pub const TRANSFORM3D: u32 = 1 << 20;
396 /// The wire `backdropFilter` chain → `BackdropInput` (the backdrop chain
397 /// resolver's *and* the backdrop transition channel's target; see
398 /// `crate::filters::backdrop`). Composite-side only, like
399 /// [`Self::TRANSFORM3D`]: a backdrop delta re-stages the snapshot filter
400 /// run and reshapes nothing in the subtree — never content dirt.
401 pub const BACKDROP: u32 = 1 << 21;
402 /// `ImageNode` from `background_image` (plus the `opacity` fold into its
403 /// tint). Built at the reconcile call sites — the build needs
404 /// `AssetServer`, which `apply_style_masked` doesn't hold — via
405 /// `crate::background_image::apply_background_image`; there is no arm for
406 /// it inside `apply_style_masked` (the end-of-apply layer content-dirty
407 /// tap still fires from this bit).
408 pub const BG_IMAGE: u32 = 1 << 22;
409}
410
411/// The single source of truth for [`Style`]'s field list. Invokes the callback
412/// macro `$cb` once with one `(ident, "wireName", (group bits), overlay-flag)`
413/// entry per field:
414///
415/// - `ident` / `"wireName"`: the Rust field and its camelCase wire name.
416/// - `(group bits)`: the [`style_groups`] whose derived output reads the field.
417/// - `overlay` / `no_overlay`: whether `overlay_style` (hover/press/focus
418/// merging) carries the field. `focus_policy` is `no_overlay` so a variant
419/// can't silently toggle pointer capture; `group_alpha`/`cache` are
420/// `no_overlay` so interaction can never flip layer promotion. `filter` IS
421/// overlaid: the merged style simply re-stamps `FilterInput`, and promotion
422/// unions variant presence (see `crate::layer::promotion_reasons`), so a
423/// hover filter composites — and, with a `transition`, eases — without ever
424/// flipping the layer.
425///
426/// Consumers: `overlay_style` (ui_map), [`Style::overlay_delta`],
427/// [`Style::unset_field`], and the field-coverage test. Adding a `Style` field
428/// without extending this table is caught by `style_field_table_is_complete`.
429macro_rules! with_style_fields {
430 ($cb:ident) => {
431 $cb! {
432 (display, "display", (LAYOUT), overlay),
433 (box_sizing, "boxSizing", (LAYOUT), overlay),
434 (position_type, "positionType", (LAYOUT), overlay),
435 (overflow_x, "overflowX", (LAYOUT), overlay),
436 (overflow_y, "overflowY", (LAYOUT), overlay),
437 (scrollbar_width, "scrollbarWidth", (LAYOUT), overlay),
438 (left, "left", (LAYOUT), overlay),
439 (right, "right", (LAYOUT), overlay),
440 (top, "top", (LAYOUT), overlay),
441 (bottom, "bottom", (LAYOUT), overlay),
442 (width, "width", (LAYOUT | TRANSITION), overlay),
443 (height, "height", (LAYOUT | TRANSITION), overlay),
444 (min_width, "minWidth", (LAYOUT), overlay),
445 (min_height, "minHeight", (LAYOUT), overlay),
446 (max_width, "maxWidth", (LAYOUT | TRANSITION), overlay),
447 (max_height, "maxHeight", (LAYOUT | TRANSITION), overlay),
448 (aspect_ratio, "aspectRatio", (LAYOUT), overlay),
449 (align_items, "alignItems", (LAYOUT), overlay),
450 (justify_items, "justifyItems", (LAYOUT), overlay),
451 (align_self, "alignSelf", (LAYOUT), overlay),
452 (justify_self, "justifySelf", (LAYOUT), overlay),
453 (align_content, "alignContent", (LAYOUT), overlay),
454 (justify_content, "justifyContent", (LAYOUT), overlay),
455 (margin, "margin", (LAYOUT), overlay),
456 (padding, "padding", (LAYOUT), overlay),
457 (border, "border", (LAYOUT), overlay),
458 (flex_direction, "flexDirection", (LAYOUT), overlay),
459 (flex_wrap, "flexWrap", (LAYOUT), overlay),
460 (flex_grow, "flexGrow", (LAYOUT), overlay),
461 (flex_shrink, "flexShrink", (LAYOUT), overlay),
462 (flex_basis, "flexBasis", (LAYOUT), overlay),
463 (gap, "gap", (LAYOUT), overlay),
464 (row_gap, "rowGap", (LAYOUT), overlay),
465 (column_gap, "columnGap", (LAYOUT), overlay),
466 (grid_auto_flow, "gridAutoFlow", (LAYOUT), overlay),
467 (grid_template_rows, "gridTemplateRows", (LAYOUT), overlay),
468 (grid_template_columns, "gridTemplateColumns", (LAYOUT), overlay),
469 (grid_auto_rows, "gridAutoRows", (LAYOUT), overlay),
470 (grid_auto_columns, "gridAutoColumns", (LAYOUT), overlay),
471 (grid_row, "gridRow", (LAYOUT), overlay),
472 (grid_column, "gridColumn", (LAYOUT), overlay),
473 (background_color, "backgroundColor", (BACKGROUND | TRANSITION), overlay),
474 (border_color, "borderColor", (BORDER_COLOR), overlay),
475 (border_radius, "borderRadius", (LAYOUT), overlay),
476 (outline, "outline", (OUTLINE), overlay),
477 (box_shadow, "boxShadow", (BOX_SHADOW), overlay),
478 (filter, "filter", (FILTER | LAYER), overlay),
479 (backdrop_filter, "backdropFilter", (BACKDROP | LAYER), overlay),
480 (background_gradient, "backgroundGradient", (BG_GRADIENT), overlay),
481 (border_gradient, "borderGradient", (BORDER_GRADIENT), overlay),
482 (background_image, "backgroundImage", (BG_IMAGE), overlay),
483 (z_index, "zIndex", (Z_INDEX), overlay),
484 (global_z_index, "globalZIndex", (GLOBAL_Z_INDEX), overlay),
485 (focus_policy, "focusPolicy", (FOCUS_POLICY), no_overlay),
486 (cursor, "cursor", (CURSOR), overlay),
487 (scrollbar, "scrollbar", (SCROLLBAR | LAYOUT), overlay),
488 (
489 transform,
490 "transform",
491 (TRANSFORM | TRANSITION),
492 overlay
493 ),
494 (
495 transform3d,
496 "transform3d",
497 (TRANSFORM3D | LAYER | TRANSITION),
498 overlay
499 ),
500 (
501 opacity,
502 "opacity",
503 (BACKGROUND | BG_GRADIENT | BORDER_GRADIENT | BG_IMAGE | TEXT_SHADOW
504 | TRANSITION | TEXT | LAYER),
505 overlay
506 ),
507 (group_alpha, "groupAlpha", (LAYER), no_overlay),
508 (cache, "cache", (LAYER), no_overlay),
509 (
510 transition,
511 "transition",
512 (TRANSITION | SCROLL_TRANSITION),
513 overlay
514 ),
515 (color, "color", (TEXT), overlay),
516 (font_size, "fontSize", (TEXT), overlay),
517 (font_weight, "fontWeight", (TEXT), overlay),
518 (font_family, "fontFamily", (TEXT), overlay),
519 (text_align, "textAlign", (TEXT_LAYOUT), overlay),
520 (line_height, "lineHeight", (TEXT), overlay),
521 (letter_spacing, "letterSpacing", (TEXT), overlay),
522 (text_shadow, "textShadow", (TEXT_SHADOW), overlay),
523 (line_break, "lineBreak", (TEXT_LAYOUT), overlay),
524 }
525 };
526}
527pub(crate) use with_style_fields;
528
529/// Which [`style_groups`] a delta update touched. `ALL` (every bit set) is the
530/// full-reapply mask used by non-delta paths.
531#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
532pub struct StyleDirty(pub u32);
533
534impl StyleDirty {
535 /// Nothing dirty — every style group can be skipped.
536 pub const NONE: Self = Self(0);
537 /// Everything dirty — full re-apply (create, hover/press restyle).
538 pub const ALL: Self = Self(u32::MAX);
539
540 /// Whether any of `groups`' bits is dirty.
541 pub fn intersects(self, groups: u32) -> bool {
542 self.0 & groups != 0
543 }
544
545 /// Whether any style field at all was touched.
546 pub fn any(self) -> bool {
547 self.0 != 0
548 }
549}
550
551impl Style {
552 /// Overlay every `Some` field of `delta` onto `self` and return the OR of
553 /// the touched fields' [`style_groups`] bits. Unlike `overlay_style` this
554 /// carries **all** fields (including the `no_overlay`-tagged ones like
555 /// `focus_policy`): the delta is the app's own base style, not a hover
556 /// variant.
557 pub(crate) fn overlay_delta(&mut self, delta: &Style) -> u32 {
558 let mut groups = 0u32;
559 macro_rules! merge_field {
560 ($(($f:ident, $name:literal, $g:tt, $ov:ident),)*) => {
561 $(
562 if delta.$f.is_some() {
563 self.$f = delta.$f.clone();
564 groups |= {
565 use style_groups::*;
566 $g
567 };
568 }
569 )*
570 };
571 }
572 with_style_fields!(merge_field);
573 groups
574 }
575
576 /// Clear the field named by `wire_name` (camelCase) and return its
577 /// [`style_groups`] bits, or `None` (after a `warn!`) for an unknown name.
578 pub(crate) fn unset_field(&mut self, wire_name: &str) -> Option<u32> {
579 macro_rules! unset_match {
580 ($(($f:ident, $name:literal, $g:tt, $ov:ident),)*) => {
581 match wire_name {
582 $(
583 $name => {
584 self.$f = None;
585 Some({
586 use style_groups::*;
587 $g
588 })
589 }
590 )*
591 _ => {
592 tracing::warn!(
593 target: "bevy_react",
594 "unknown style field {wire_name:?} in styleUnset; ignoring"
595 );
596 None
597 }
598 }
599 };
600 }
601 with_style_fields!(unset_match)
602 }
603}
604
605#[cfg(test)]
606mod tests {
607 use super::*;
608 use crate::protocol::animatable::AnimatableField;
609 use crate::protocol::props::{Props, props_from_json as props};
610
611 /// `groupAlpha` decodes as a plain bool, defaults to absent, and its wire
612 /// delta dirties the `LAYER` group (the promotion evaluator's trigger),
613 /// as does `opacity`.
614 #[test]
615 fn group_alpha_decodes_and_dirties_layer() {
616 let s: Style = serde_json::from_str(r#"{ "groupAlpha": false }"#).expect("style decodes");
617 assert_eq!(s.group_alpha, Some(false));
618 let s: Style = serde_json::from_str("{}").expect("style decodes");
619 assert_eq!(s.group_alpha, None);
620
621 // Delta-merge marks the LAYER group for both trigger fields.
622 let mut cached = Props::default();
623 let (dirty, _) = cached.merge_delta(
624 props(serde_json::json!({ "style": { "groupAlpha": false } })),
625 &[],
626 &[],
627 );
628 assert!(dirty.style.intersects(style_groups::LAYER));
629 let (dirty, _) = cached.merge_delta(
630 props(serde_json::json!({ "style": { "opacity": 0.5 } })),
631 &[],
632 &[],
633 );
634 assert!(dirty.style.intersects(style_groups::LAYER));
635 let style = cached.style.as_ref().expect("style retained");
636 assert_eq!(style.group_alpha, Some(false));
637 assert_eq!(style.opacity.static_val(), Some(0.5));
638 }
639
640 /// `cache` decodes its keywords (unknown → warn + default) and a delta
641 /// touching it marks the LAYER group, driving promotion re-evaluation.
642 #[test]
643 fn cache_keyword_decodes_and_dirties_layer() {
644 let s: Style = serde_json::from_str(r#"{ "cache": "always" }"#).expect("style decodes");
645 assert_eq!(s.cache, Some(LayerCache::Always));
646 let s: Style = serde_json::from_str(r#"{ "cache": "auto" }"#).expect("style decodes");
647 assert_eq!(s.cache, Some(LayerCache::Auto));
648 let s: Style = serde_json::from_str(r#"{ "cache": "never" }"#).expect("style decodes");
649 assert_eq!(s.cache, Some(LayerCache::Never));
650 let s: Style = serde_json::from_str("{}").expect("style decodes");
651 assert_eq!(s.cache, None);
652 // Unrecognized keyword: warn + fall back to the default (`auto`).
653 let s: Style = serde_json::from_str(r#"{ "cache": "sometimes" }"#).expect("style decodes");
654 assert_eq!(s.cache, Some(LayerCache::Auto));
655
656 let mut cached = Props::default();
657 let (dirty, _) = cached.merge_delta(
658 props(serde_json::json!({ "style": { "cache": "always" } })),
659 &[],
660 &[],
661 );
662 assert!(dirty.style.intersects(style_groups::LAYER));
663 assert_eq!(
664 cached.style.as_ref().and_then(|s| s.cache),
665 Some(LayerCache::Always)
666 );
667 }
668
669 /// A `filter` decodes *through* `Style` into the layer-based chain (the
670 /// chain's own decode is unit-tested in `crate::filters`): a single
671 /// `{name, params}` object is a 1-element chain, an array preserves order,
672 /// and a malformed entry degrades the whole chain to empty without
673 /// aborting the containing `Style`.
674 #[test]
675 fn deserializes_filter_chain() {
676 use crate::filters::FilterChain;
677
678 // A single object is a 1-element chain; params stay a raw map.
679 let s: Style =
680 serde_json::from_str(r#"{ "filter": { "name": "blur", "params": { "radius": 4 } } }"#)
681 .expect("filter decodes");
682 let chain = s.filter.expect("filter present");
683 assert_eq!(chain.0.len(), 1);
684 assert_eq!(chain.0[0].name, "blur");
685 assert_eq!(chain.0[0].params["radius"], serde_json::json!(4));
686
687 // An array preserves declaration order (chain order = pass order).
688 let s: Style =
689 serde_json::from_str(r#"{ "filter": [{ "name": "blur" }, { "name": "grayscale" }] }"#)
690 .expect("filter decodes");
691 let names: Vec<&str> = s
692 .filter
693 .as_ref()
694 .expect("filter present")
695 .0
696 .iter()
697 .map(|u| u.name.as_str())
698 .collect();
699 assert_eq!(names, ["blur", "grayscale"]);
700
701 // A malformed entry degrades the whole chain to empty without
702 // aborting the Style — the sibling field still decodes.
703 let s: Style =
704 serde_json::from_str(r#"{ "filter": [{ "name": "blur" }, 3], "opacity": 0.5 }"#)
705 .expect("a bad filter entry must not abort the style");
706 assert_eq!(s.filter, Some(FilterChain::default()));
707 assert_eq!(s.opacity.static_val(), Some(0.5));
708 }
709
710 /// A `filter` delta dirties FILTER (the `FilterInput` re-stamp) and LAYER
711 /// (the promotion evaluator's trigger); a variant carrying a filter rides
712 /// the `hover_style` flag, which the reconciler also treats as a layer
713 /// trigger (variant filters promote — the field is `overlay`).
714 #[test]
715 fn filter_delta_dirties_filter_and_layer() {
716 let mut cached = Props::default();
717 let (dirty, _) = cached.merge_delta(
718 props(serde_json::json!({ "style": { "filter": { "name": "blur" } } })),
719 &[],
720 &[],
721 );
722 assert!(dirty.style.intersects(style_groups::FILTER));
723 assert!(dirty.style.intersects(style_groups::LAYER));
724
725 let (dirty, _) = cached.merge_delta(
726 props(serde_json::json!({ "hoverStyle": { "filter": { "name": "blur" } } })),
727 &[],
728 &[],
729 );
730 assert!(dirty.hover_style);
731 let hover = cached.hover_style.as_ref().expect("variant retained");
732 assert!(hover.filter.is_some(), "variant carries the chain");
733 }
734
735 /// A `backdropFilter` delta dirties BACKDROP (the `BackdropInput`
736 /// re-stamp) and LAYER (the promotion trigger) — and never FILTER: the
737 /// two chains are independent channels. `styleUnset` re-fires the same
738 /// groups so the removal reaches the apply arm and the evaluator.
739 #[test]
740 fn backdrop_filter_delta_dirties_backdrop_and_layer() {
741 let mut cached = Props::default();
742 let (dirty, _) = cached.merge_delta(
743 props(serde_json::json!({ "style": { "backdropFilter": { "name": "blur" } } })),
744 &[],
745 &[],
746 );
747 assert!(dirty.style.intersects(style_groups::BACKDROP));
748 assert!(dirty.style.intersects(style_groups::LAYER));
749 assert!(!dirty.style.intersects(style_groups::FILTER));
750 assert!(
751 cached
752 .style
753 .as_ref()
754 .is_some_and(|s| s.backdrop_filter.is_some())
755 );
756
757 let (dirty, _) = cached.merge_delta(Props::default(), &[], &["backdropFilter".into()]);
758 assert!(dirty.style.intersects(style_groups::BACKDROP));
759 assert!(dirty.style.intersects(style_groups::LAYER));
760 assert!(
761 cached
762 .style
763 .as_ref()
764 .is_some_and(|s| s.backdrop_filter.is_none())
765 );
766 }
767
768 /// Compile-time completeness guard: a `Style` struct literal built from the
769 /// field table must name every field — adding a `Style` field without
770 /// extending `with_style_fields!` fails this with E0063 (missing field).
771 #[test]
772 fn style_field_table_is_complete() {
773 macro_rules! build_full {
774 ($(($f:ident, $name:literal, $g:tt, $ov:ident),)*) => {
775 Style { $($f: None,)* }
776 };
777 }
778 let _style: Style = with_style_fields!(build_full);
779 }
780
781 /// Every table wire name must equal serde's `rename_all = "camelCase"`
782 /// rendering of the field ident, or `unset_field`/the JS delta builder
783 /// would miss the field.
784 #[test]
785 fn style_wire_names_match_serde_rename() {
786 fn camel(s: &str) -> String {
787 let mut out = String::new();
788 let mut up = false;
789 for c in s.chars() {
790 if c == '_' {
791 up = true;
792 } else if up {
793 out.extend(c.to_uppercase());
794 up = false;
795 } else {
796 out.push(c);
797 }
798 }
799 out
800 }
801 macro_rules! check {
802 ($(($f:ident, $name:literal, $g:tt, $ov:ident),)*) => {
803 $( assert_eq!(camel(stringify!($f)), $name, "table wire name for `{}`", stringify!($f)); )*
804 };
805 }
806 with_style_fields!(check);
807 }
808}