Skip to main content

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}