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    /// View-transition-style morph: `{ key, name, params }`. When `key`
198    /// changes, the node's previous rendered appearance is frozen as a
199    /// snapshot and the named two-input filter (same registry as
200    /// [`filter`](Self::filter)) blends frozen → live content, driven by an
201    /// engine-owned `progress` eased by `transition: { morphFilter }` (a
202    /// built-in default duration applies when no spec is given — the one
203    /// channel that animates without being asked). Presence force-promotes
204    /// the node to a composited layer (a cached capture must exist to
205    /// freeze); unsetting demotes and snaps. See [`crate::filters`] (morph).
206    #[serde(default, deserialize_with = "crate::filters::de_morph_filter")]
207    pub morph_filter: Option<crate::filters::MorphFilter>,
208    /// Background gradient(s); one gradient or a layered list. bevy paints it
209    /// *over* `backgroundColor` (CSS `background-image` semantics): an opaque
210    /// gradient hides the color (fallback); transparent stops reveal it.
211    #[serde(default)]
212    pub background_gradient: Option<GradientList>,
213    /// Border gradient(s); one gradient or a layered list. Painted *over*
214    /// `borderColor` (needs a `border` width to be visible).
215    #[serde(default)]
216    pub border_gradient: Option<GradientList>,
217    /// Background image: painted *over* `backgroundColor` **and**
218    /// `backgroundGradient`, under the node's content (bevy's fixed per-node
219    /// paint order). `src` is an asset path, or `{ texture }` naming a render
220    /// target registered in `crate::portal::RenderTargets`. Never affects
221    /// layout (the layout-driving `Auto` image mode is never emitted).
222    /// Ignored — with a devtools warning — on `image`/`canvas`/`portal`
223    /// (their `ImageNode` belongs to the element) and `surface`.
224    #[serde(default, deserialize_with = "de_background_image")]
225    pub background_image: Option<BackgroundImageSpec>,
226    #[serde(default)]
227    pub z_index: Option<i32>,
228    /// Global stacking order: lifts the node (and its subtree) into the UI's
229    /// top-level stack, escaping the parent stacking context. Unlike [`z_index`](Self::z_index),
230    /// which only reorders a node among its siblings.
231    #[serde(default)]
232    pub global_z_index: Option<i32>,
233    /// Pointer pass-through. Maps to `bevy::ui::FocusPolicy`. `"pass"` lets pointer
234    /// interaction fall through to nodes behind this one; `"block"` makes it
235    /// *capture* interaction so siblings, the 3D scene, and portals behind it don't
236    /// receive it. When unset the default is element-dependent (set in the
237    /// reconciler): a `<button>` blocks, a `<node>`/container passes.
238    #[serde(default, deserialize_with = "de_focus_policy")]
239    pub focus_policy: Option<FocusPolicy>,
240    /// Mouse cursor shown while the pointer is over this node (CSS `cursor`).
241    /// A system keyword (winit's `SystemCursorIcon`) or a custom-cursor name
242    /// registered via `ReactUiPlugin::cursor`; the name is resolved (registry first,
243    /// so a custom cursor can override a system keyword) onto the window's
244    /// `CursorIcon` by `crate::cursor::drive_cursor_icon`. Like `font_family`, a raw
245    /// name resolved at drive time. Absent → the node contributes no cursor (its
246    /// ancestor's or the default arrow shows).
247    #[serde(default)]
248    pub cursor: Option<String>,
249
250    // --- transform / opacity (drive `UiTransform` and color alpha) ---
251    /// Static transform (translate/scale/rotate). Mirrors the animated transform
252    /// channels; written to `UiTransform`. With a [`transition`](Self::transition)
253    /// a change eases instead of snapping.
254    #[serde(default)]
255    pub transform: Option<Transform>,
256    /// 3D perspective transform, applied to the subtree's *rendered result* at
257    /// composite time (group semantics, like `opacity`/`filter`). Presence —
258    /// even an identity `{}` — promotes the subtree to a composited layer (see
259    /// [`crate::layer`]); the captured texture is drawn as one quad through the
260    /// matrix, so animating it never re-captures. Unlike `transform` (which
261    /// stays main-world and bakes into the capture), this never touches layout,
262    /// and ancestor clips clamp the transformed result. With a
263    /// [`transition`](Self::transition) a change eases field-wise.
264    #[serde(default)]
265    pub transform3d: Option<Transform3d>,
266    /// Opacity in `0.0..=1.0`, multiplied into the alpha of the background (and
267    /// text) color. With a [`transition`](Self::transition) a change eases.
268    /// On a node with children (unless [`group_alpha`](Self::group_alpha) is
269    /// `false`) the subtree is instead promoted to a composited layer and the
270    /// value applies once to the whole group — see [`crate::layer`].
271    #[serde(default)]
272    pub opacity: Option<Animatable<f32>>,
273    /// Whether `opacity` on a node with children fades the subtree as a group
274    /// (composited layer) rather than folding into each node's own colors.
275    /// Default `true` (web semantics); `false` opts out of layer promotion for
276    /// perf-sensitive spots, keeping the per-node fold. `no_overlay`: a hover/
277    /// press variant must not be able to flip promotion.
278    #[serde(default)]
279    pub group_alpha: Option<bool>,
280    /// Layer-cache hint. `"always"` force-promotes the subtree to a composited
281    /// layer (see [`crate::layer`]) so its capture is cached and re-rendered
282    /// only when its content changes — the `will-change` pattern for static
283    /// or transform/opacity-animated subtrees. `"never"` also force-promotes,
284    /// but the capture re-runs **every frame** — for content written outside
285    /// the dirt tracking's sight (live `<portal>` targets, app-owned textures).
286    /// `"auto"` (or absent, the default) promotes only when another rule does
287    /// (today: `opacity`). `no_overlay`: a variant must not flip promotion.
288    #[serde(default, deserialize_with = "de_layer_cache")]
289    pub cache: Option<LayerCache>,
290    /// CSS-like per-channel transition timing. Present → a change to `transform` /
291    /// `opacity` / `backgroundColor` (via re-render or hover/press) animates over
292    /// time using the same driver/easing engine as `{ animated }` bindings, rather than
293    /// snapping. See [`crate::transition`].
294    #[serde(default)]
295    pub transition: Option<crate::transition::Transition>,
296
297    /// Visible scrollbar for an `overflow: scroll` node: `"none"` (default) /
298    /// `"default"` / a styled object. Present → the reconciler stamps a
299    /// [`crate::scrollbar::ScrollbarConfig`] and the shell spawns Bevy's headless
300    /// scrollbar widget over the container. Pure-serde, module-owned.
301    #[serde(default)]
302    pub scrollbar: Option<crate::scrollbar::ScrollbarSpec>,
303
304    // --- text (only meaningful on `<text>` elements/spans) ---
305    /// Hex text color. Animated via an `interpolateColor` binding.
306    #[serde(default)]
307    pub color: Option<Animatable<String>>,
308    /// Font size: a number (logical pixels) or a unit string (`"24px"`, `"2vw"`,
309    /// `"1.5rem"`). See [`FontSize`].
310    #[serde(default)]
311    pub font_size: Option<FontSize>,
312    /// `"thin" | "light" | "normal" | "medium" | "semibold" | "bold" | "black"`
313    /// or a numeric weight string (e.g. `"600"`).
314    #[serde(default, deserialize_with = "de_font_weight")]
315    pub font_weight: Option<FontWeight>,
316    /// Registered font-family name to render this text with (see the plugin's
317    /// `default_font`/`font` config). Unknown or unset → the configured default
318    /// font.
319    #[serde(default)]
320    pub font_family: Option<String>,
321    /// Horizontal alignment of the text block (`<text>` root only):
322    /// `"left" | "center" | "right" | "justify" | "start" | "end"`.
323    #[serde(default, deserialize_with = "de_text_align")]
324    pub text_align: Option<Justify>,
325    /// Line height. A bare number is a multiple of the font size; `{ "px": n }`
326    /// is an absolute pixel height. Unset → bevy's default (1.2× the font size).
327    #[serde(default)]
328    pub line_height: Option<LineHeightSpec>,
329    /// Letter spacing. A bare number is logical pixels; `{ "rem": n }` is a
330    /// multiple of the font size. Unset → no extra spacing.
331    #[serde(default)]
332    pub letter_spacing: Option<LetterSpacingSpec>,
333    /// A single drop shadow behind the text (`<text>` root only).
334    #[serde(default)]
335    pub text_shadow: Option<TextShadowSpec>,
336    /// How the text wraps when it overflows its bounds (`<text>` root only):
337    /// `"wordBoundary"` (default) | `"anyCharacter"` | `"wordOrCharacter"` |
338    /// `"noWrap"`.
339    #[serde(default, deserialize_with = "de_line_break")]
340    pub line_break: Option<LineBreak>,
341}
342
343/// Bit flags naming the groups of work [`crate::ui_map::apply_style`] (and the
344/// update reconciler) derive from a [`Style`]. Each [`Style`] field belongs to
345/// the group(s) whose output reads it (see [`with_style_fields`]); a delta
346/// update ORs the groups of its touched fields into a [`StyleDirty`] mask so
347/// the apply path can skip every group the delta provably didn't affect.
348pub mod style_groups {
349    /// `bevy_ui::Node` (`node_from_style`): every layout field.
350    pub const LAYOUT: u32 = 1 << 0;
351    /// `BackgroundColor` (reads `background_color`, `opacity`).
352    pub const BACKGROUND: u32 = 1 << 1;
353    /// `UiTransform` (reads `transform`).
354    pub const TRANSFORM: u32 = 1 << 2;
355    /// `BorderColor`.
356    pub const BORDER_COLOR: u32 = 1 << 3;
357    /// `Outline`.
358    pub const OUTLINE: u32 = 1 << 4;
359    /// `BoxShadow`.
360    pub const BOX_SHADOW: u32 = 1 << 5;
361    /// `BackgroundGradient` (reads `background_gradient`, `opacity`).
362    pub const BG_GRADIENT: u32 = 1 << 6;
363    /// `BorderGradient` (reads `border_gradient`, `opacity`).
364    pub const BORDER_GRADIENT: u32 = 1 << 7;
365    /// `TextShadow` (reads `text_shadow`, `opacity`).
366    pub const TEXT_SHADOW: u32 = 1 << 8;
367    /// `ZIndex`.
368    pub const Z_INDEX: u32 = 1 << 9;
369    /// `GlobalZIndex`.
370    pub const GLOBAL_Z_INDEX: u32 = 1 << 10;
371    /// `FocusPolicy` (also `apply_button_focus_default` in the reconciler).
372    pub const FOCUS_POLICY: u32 = 1 << 11;
373    /// The wire `filter` chain → `FilterInput` (the chain resolver's *and*
374    /// the transition filter channel's target; see `crate::filters`).
375    pub const FILTER: u32 = 1 << 12;
376    /// `TransitionInput` (`TransitionInput::from_style` reads `transition` plus
377    /// every transitioned channel: `transform`, `opacity`, `background_color`,
378    /// `width`, `height`, `max_width`, `max_height`). The filter channel's
379    /// timing rides the spec here; its *target* is `FilterInput` (FILTER).
380    pub const TRANSITION: u32 = 1 << 13;
381    /// `ScrollTransitionInput` (reads `transition`).
382    pub const SCROLL_TRANSITION: u32 = 1 << 14;
383    /// The resolved text style (`resolved_text_style`: `color`, `font_size`,
384    /// `font_weight`, `font_family`, `line_height`, `letter_spacing`,
385    /// `opacity`) — includes the `<text>` re-propagation to inheriting spans.
386    pub const TEXT: u32 = 1 << 15;
387    /// `TextLayout` (`text_layout`: `text_align`, `line_break`).
388    pub const TEXT_LAYOUT: u32 = 1 << 16;
389    /// `NodeCursor` (reads `cursor`) — the per-node cursor `drive_cursor_icon`
390    /// writes onto the window's `CursorIcon` on hover.
391    pub const CURSOR: u32 = 1 << 17;
392    /// `ScrollbarConfig` (reads `scrollbar`) — the visible scrollbar shell
393    /// (`crate::scrollbar`) spawns/updates Bevy's scrollbar widget from it. The
394    /// field is *also* in `LAYOUT` because a gutter-positioned bar drives
395    /// `Node.scrollbar_width` (see `node_from_style`).
396    pub const SCROLLBAR: u32 = 1 << 18;
397    /// Layer-promotion inputs (`crate::layer`): fields that change whether a
398    /// subtree composites as a layer (`opacity`, `group_alpha`, `cache`,
399    /// `filter`, `transform3d`). No `apply_style` output reads this group — it
400    /// exists so a delta touching a promotion trigger is visible to the
401    /// promotion evaluator.
402    pub const LAYER: u32 = 1 << 19;
403    /// `LayerTransform3d` (reads `transform3d`) — the composite-time 3D
404    /// transform on a promoted layer (`crate::layer::transform3d`). Never
405    /// content dirt: matrix changes reshape the composite quad only.
406    pub const TRANSFORM3D: u32 = 1 << 20;
407    /// The wire `backdropFilter` chain → `BackdropInput` (the backdrop chain
408    /// resolver's *and* the backdrop transition channel's target; see
409    /// `crate::filters::backdrop`). Composite-side only, like
410    /// [`Self::TRANSFORM3D`]: a backdrop delta re-stages the snapshot filter
411    /// run and reshapes nothing in the subtree — never content dirt.
412    pub const BACKDROP: u32 = 1 << 21;
413    /// `ImageNode` from `background_image` (plus the `opacity` fold into its
414    /// tint). Built at the reconcile call sites — the build needs
415    /// `AssetServer`, which `apply_style_masked` doesn't hold — via
416    /// `crate::background_image::apply_background_image`; there is no arm for
417    /// it inside `apply_style_masked` (the end-of-apply layer content-dirty
418    /// tap still fires from this bit).
419    pub const BG_IMAGE: u32 = 1 << 22;
420    /// The wire `morphFilter` value → `MorphInput` (the morph resolver's and
421    /// the morph transition channel's target; see `crate::filters` morph).
422    /// Composite-side only, like [`Self::BACKDROP`]: a morph delta re-stages
423    /// the blend pass and never dirties the capture itself — the key-change
424    /// re-capture is pushed precisely by the transition channel. The
425    /// `apply_transition` stamp site fires on `TRANSITION | MORPH` so a
426    /// morph-only delta still reaches the transition engine.
427    pub const MORPH: u32 = 1 << 23;
428}
429
430/// The single source of truth for [`Style`]'s field list. Invokes the callback
431/// macro `$cb` once with one `(ident, "wireName", (group bits), overlay-flag)`
432/// entry per field:
433///
434/// - `ident` / `"wireName"`: the Rust field and its camelCase wire name.
435/// - `(group bits)`: the [`style_groups`] whose derived output reads the field.
436/// - `overlay` / `no_overlay`: whether `overlay_style` (hover/press/focus
437///   merging) carries the field. `focus_policy` is `no_overlay` so a variant
438///   can't silently toggle pointer capture; `group_alpha`/`cache` are
439///   `no_overlay` so interaction can never flip layer promotion. `filter` IS
440///   overlaid: the merged style simply re-stamps `FilterInput`, and promotion
441///   unions variant presence (see `crate::layer::promotion_reasons`), so a
442///   hover filter composites — and, with a `transition`, eases — without ever
443///   flipping the layer.
444///
445/// Consumers: `overlay_style` (ui_map), [`Style::overlay_delta`],
446/// [`Style::unset_field`], and the field-coverage test. Adding a `Style` field
447/// without extending this table is caught by `style_field_table_is_complete`.
448macro_rules! with_style_fields {
449    ($cb:ident) => {
450        $cb! {
451            (display, "display", (LAYOUT), overlay),
452            (box_sizing, "boxSizing", (LAYOUT), overlay),
453            (position_type, "positionType", (LAYOUT), overlay),
454            (overflow_x, "overflowX", (LAYOUT), overlay),
455            (overflow_y, "overflowY", (LAYOUT), overlay),
456            (scrollbar_width, "scrollbarWidth", (LAYOUT), overlay),
457            (left, "left", (LAYOUT), overlay),
458            (right, "right", (LAYOUT), overlay),
459            (top, "top", (LAYOUT), overlay),
460            (bottom, "bottom", (LAYOUT), overlay),
461            (width, "width", (LAYOUT | TRANSITION), overlay),
462            (height, "height", (LAYOUT | TRANSITION), overlay),
463            (min_width, "minWidth", (LAYOUT), overlay),
464            (min_height, "minHeight", (LAYOUT), overlay),
465            (max_width, "maxWidth", (LAYOUT | TRANSITION), overlay),
466            (max_height, "maxHeight", (LAYOUT | TRANSITION), overlay),
467            (aspect_ratio, "aspectRatio", (LAYOUT), overlay),
468            (align_items, "alignItems", (LAYOUT), overlay),
469            (justify_items, "justifyItems", (LAYOUT), overlay),
470            (align_self, "alignSelf", (LAYOUT), overlay),
471            (justify_self, "justifySelf", (LAYOUT), overlay),
472            (align_content, "alignContent", (LAYOUT), overlay),
473            (justify_content, "justifyContent", (LAYOUT), overlay),
474            (margin, "margin", (LAYOUT), overlay),
475            (padding, "padding", (LAYOUT), overlay),
476            (border, "border", (LAYOUT), overlay),
477            (flex_direction, "flexDirection", (LAYOUT), overlay),
478            (flex_wrap, "flexWrap", (LAYOUT), overlay),
479            (flex_grow, "flexGrow", (LAYOUT), overlay),
480            (flex_shrink, "flexShrink", (LAYOUT), overlay),
481            (flex_basis, "flexBasis", (LAYOUT), overlay),
482            (gap, "gap", (LAYOUT), overlay),
483            (row_gap, "rowGap", (LAYOUT), overlay),
484            (column_gap, "columnGap", (LAYOUT), overlay),
485            (grid_auto_flow, "gridAutoFlow", (LAYOUT), overlay),
486            (grid_template_rows, "gridTemplateRows", (LAYOUT), overlay),
487            (grid_template_columns, "gridTemplateColumns", (LAYOUT), overlay),
488            (grid_auto_rows, "gridAutoRows", (LAYOUT), overlay),
489            (grid_auto_columns, "gridAutoColumns", (LAYOUT), overlay),
490            (grid_row, "gridRow", (LAYOUT), overlay),
491            (grid_column, "gridColumn", (LAYOUT), overlay),
492            (background_color, "backgroundColor", (BACKGROUND | TRANSITION), overlay),
493            (border_color, "borderColor", (BORDER_COLOR), overlay),
494            (border_radius, "borderRadius", (LAYOUT), overlay),
495            (outline, "outline", (OUTLINE), overlay),
496            (box_shadow, "boxShadow", (BOX_SHADOW), overlay),
497            (filter, "filter", (FILTER | LAYER), overlay),
498            (backdrop_filter, "backdropFilter", (BACKDROP | LAYER), overlay),
499            (morph_filter, "morphFilter", (MORPH | LAYER), overlay),
500            (background_gradient, "backgroundGradient", (BG_GRADIENT), overlay),
501            (border_gradient, "borderGradient", (BORDER_GRADIENT), overlay),
502            (background_image, "backgroundImage", (BG_IMAGE), overlay),
503            (z_index, "zIndex", (Z_INDEX), overlay),
504            (global_z_index, "globalZIndex", (GLOBAL_Z_INDEX), overlay),
505            (focus_policy, "focusPolicy", (FOCUS_POLICY), no_overlay),
506            (cursor, "cursor", (CURSOR), overlay),
507            (scrollbar, "scrollbar", (SCROLLBAR | LAYOUT), overlay),
508            (
509                transform,
510                "transform",
511                (TRANSFORM | TRANSITION),
512                overlay
513            ),
514            (
515                transform3d,
516                "transform3d",
517                (TRANSFORM3D | LAYER | TRANSITION),
518                overlay
519            ),
520            (
521                opacity,
522                "opacity",
523                (BACKGROUND | BG_GRADIENT | BORDER_GRADIENT | BG_IMAGE | TEXT_SHADOW
524                    | TRANSITION | TEXT | LAYER),
525                overlay
526            ),
527            (group_alpha, "groupAlpha", (LAYER), no_overlay),
528            (cache, "cache", (LAYER), no_overlay),
529            (
530                transition,
531                "transition",
532                (TRANSITION | SCROLL_TRANSITION),
533                overlay
534            ),
535            (color, "color", (TEXT), overlay),
536            (font_size, "fontSize", (TEXT), overlay),
537            (font_weight, "fontWeight", (TEXT), overlay),
538            (font_family, "fontFamily", (TEXT), overlay),
539            (text_align, "textAlign", (TEXT_LAYOUT), overlay),
540            (line_height, "lineHeight", (TEXT), overlay),
541            (letter_spacing, "letterSpacing", (TEXT), overlay),
542            (text_shadow, "textShadow", (TEXT_SHADOW), overlay),
543            (line_break, "lineBreak", (TEXT_LAYOUT), overlay),
544        }
545    };
546}
547pub(crate) use with_style_fields;
548
549/// Which [`style_groups`] a delta update touched. `ALL` (every bit set) is the
550/// full-reapply mask used by non-delta paths.
551#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
552pub struct StyleDirty(pub u32);
553
554impl StyleDirty {
555    /// Nothing dirty — every style group can be skipped.
556    pub const NONE: Self = Self(0);
557    /// Everything dirty — full re-apply (create, hover/press restyle).
558    pub const ALL: Self = Self(u32::MAX);
559
560    /// Whether any of `groups`' bits is dirty.
561    pub fn intersects(self, groups: u32) -> bool {
562        self.0 & groups != 0
563    }
564
565    /// Whether any style field at all was touched.
566    pub fn any(self) -> bool {
567        self.0 != 0
568    }
569}
570
571impl Style {
572    /// Overlay every `Some` field of `delta` onto `self` and return the OR of
573    /// the touched fields' [`style_groups`] bits. Unlike `overlay_style` this
574    /// carries **all** fields (including the `no_overlay`-tagged ones like
575    /// `focus_policy`): the delta is the app's own base style, not a hover
576    /// variant.
577    pub(crate) fn overlay_delta(&mut self, delta: &Style) -> u32 {
578        let mut groups = 0u32;
579        macro_rules! merge_field {
580            ($(($f:ident, $name:literal, $g:tt, $ov:ident),)*) => {
581                $(
582                    if delta.$f.is_some() {
583                        self.$f = delta.$f.clone();
584                        groups |= {
585                            use style_groups::*;
586                            $g
587                        };
588                    }
589                )*
590            };
591        }
592        with_style_fields!(merge_field);
593        groups
594    }
595
596    /// Clear the field named by `wire_name` (camelCase) and return its
597    /// [`style_groups`] bits, or `None` (after a `warn!`) for an unknown name.
598    pub(crate) fn unset_field(&mut self, wire_name: &str) -> Option<u32> {
599        macro_rules! unset_match {
600            ($(($f:ident, $name:literal, $g:tt, $ov:ident),)*) => {
601                match wire_name {
602                    $(
603                        $name => {
604                            self.$f = None;
605                            Some({
606                                use style_groups::*;
607                                $g
608                            })
609                        }
610                    )*
611                    _ => {
612                        tracing::warn!(
613                            target: "bevy_react",
614                            "unknown style field {wire_name:?} in styleUnset; ignoring"
615                        );
616                        None
617                    }
618                }
619            };
620        }
621        with_style_fields!(unset_match)
622    }
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628    use crate::protocol::animatable::AnimatableField;
629    use crate::protocol::props::{Props, props_from_json as props};
630
631    /// `groupAlpha` decodes as a plain bool, defaults to absent, and its wire
632    /// delta dirties the `LAYER` group (the promotion evaluator's trigger),
633    /// as does `opacity`.
634    #[test]
635    fn group_alpha_decodes_and_dirties_layer() {
636        let s: Style = serde_json::from_str(r#"{ "groupAlpha": false }"#).expect("style decodes");
637        assert_eq!(s.group_alpha, Some(false));
638        let s: Style = serde_json::from_str("{}").expect("style decodes");
639        assert_eq!(s.group_alpha, None);
640
641        // Delta-merge marks the LAYER group for both trigger fields.
642        let mut cached = Props::default();
643        let (dirty, _) = cached.merge_delta(
644            props(serde_json::json!({ "style": { "groupAlpha": false } })),
645            &[],
646            &[],
647        );
648        assert!(dirty.style.intersects(style_groups::LAYER));
649        let (dirty, _) = cached.merge_delta(
650            props(serde_json::json!({ "style": { "opacity": 0.5 } })),
651            &[],
652            &[],
653        );
654        assert!(dirty.style.intersects(style_groups::LAYER));
655        let style = cached.style.as_ref().expect("style retained");
656        assert_eq!(style.group_alpha, Some(false));
657        assert_eq!(style.opacity.static_val(), Some(0.5));
658    }
659
660    /// `cache` decodes its keywords (unknown → warn + default) and a delta
661    /// touching it marks the LAYER group, driving promotion re-evaluation.
662    #[test]
663    fn cache_keyword_decodes_and_dirties_layer() {
664        let s: Style = serde_json::from_str(r#"{ "cache": "always" }"#).expect("style decodes");
665        assert_eq!(s.cache, Some(LayerCache::Always));
666        let s: Style = serde_json::from_str(r#"{ "cache": "auto" }"#).expect("style decodes");
667        assert_eq!(s.cache, Some(LayerCache::Auto));
668        let s: Style = serde_json::from_str(r#"{ "cache": "never" }"#).expect("style decodes");
669        assert_eq!(s.cache, Some(LayerCache::Never));
670        let s: Style = serde_json::from_str("{}").expect("style decodes");
671        assert_eq!(s.cache, None);
672        // Unrecognized keyword: warn + fall back to the default (`auto`).
673        let s: Style = serde_json::from_str(r#"{ "cache": "sometimes" }"#).expect("style decodes");
674        assert_eq!(s.cache, Some(LayerCache::Auto));
675
676        let mut cached = Props::default();
677        let (dirty, _) = cached.merge_delta(
678            props(serde_json::json!({ "style": { "cache": "always" } })),
679            &[],
680            &[],
681        );
682        assert!(dirty.style.intersects(style_groups::LAYER));
683        assert_eq!(
684            cached.style.as_ref().and_then(|s| s.cache),
685            Some(LayerCache::Always)
686        );
687    }
688
689    /// A `filter` decodes *through* `Style` into the layer-based chain (the
690    /// chain's own decode is unit-tested in `crate::filters`): a single
691    /// `{name, params}` object is a 1-element chain, an array preserves order,
692    /// and a malformed entry degrades the whole chain to empty without
693    /// aborting the containing `Style`.
694    #[test]
695    fn deserializes_filter_chain() {
696        use crate::filters::FilterChain;
697
698        // A single object is a 1-element chain; params stay a raw map.
699        let s: Style =
700            serde_json::from_str(r#"{ "filter": { "name": "blur", "params": { "radius": 4 } } }"#)
701                .expect("filter decodes");
702        let chain = s.filter.expect("filter present");
703        assert_eq!(chain.0.len(), 1);
704        assert_eq!(chain.0[0].name, "blur");
705        assert_eq!(chain.0[0].params["radius"], serde_json::json!(4));
706
707        // An array preserves declaration order (chain order = pass order).
708        let s: Style =
709            serde_json::from_str(r#"{ "filter": [{ "name": "blur" }, { "name": "grayscale" }] }"#)
710                .expect("filter decodes");
711        let names: Vec<&str> = s
712            .filter
713            .as_ref()
714            .expect("filter present")
715            .0
716            .iter()
717            .map(|u| u.name.as_str())
718            .collect();
719        assert_eq!(names, ["blur", "grayscale"]);
720
721        // A malformed entry degrades the whole chain to empty without
722        // aborting the Style — the sibling field still decodes.
723        let s: Style =
724            serde_json::from_str(r#"{ "filter": [{ "name": "blur" }, 3], "opacity": 0.5 }"#)
725                .expect("a bad filter entry must not abort the style");
726        assert_eq!(s.filter, Some(FilterChain::default()));
727        assert_eq!(s.opacity.static_val(), Some(0.5));
728    }
729
730    /// A `filter` delta dirties FILTER (the `FilterInput` re-stamp) and LAYER
731    /// (the promotion evaluator's trigger); a variant carrying a filter rides
732    /// the `hover_style` flag, which the reconciler also treats as a layer
733    /// trigger (variant filters promote — the field is `overlay`).
734    #[test]
735    fn filter_delta_dirties_filter_and_layer() {
736        let mut cached = Props::default();
737        let (dirty, _) = cached.merge_delta(
738            props(serde_json::json!({ "style": { "filter": { "name": "blur" } } })),
739            &[],
740            &[],
741        );
742        assert!(dirty.style.intersects(style_groups::FILTER));
743        assert!(dirty.style.intersects(style_groups::LAYER));
744
745        let (dirty, _) = cached.merge_delta(
746            props(serde_json::json!({ "hoverStyle": { "filter": { "name": "blur" } } })),
747            &[],
748            &[],
749        );
750        assert!(dirty.hover_style);
751        let hover = cached.hover_style.as_ref().expect("variant retained");
752        assert!(hover.filter.is_some(), "variant carries the chain");
753    }
754
755    /// A `backdropFilter` delta dirties BACKDROP (the `BackdropInput`
756    /// re-stamp) and LAYER (the promotion trigger) — and never FILTER: the
757    /// two chains are independent channels. `styleUnset` re-fires the same
758    /// groups so the removal reaches the apply arm and the evaluator.
759    #[test]
760    fn backdrop_filter_delta_dirties_backdrop_and_layer() {
761        let mut cached = Props::default();
762        let (dirty, _) = cached.merge_delta(
763            props(serde_json::json!({ "style": { "backdropFilter": { "name": "blur" } } })),
764            &[],
765            &[],
766        );
767        assert!(dirty.style.intersects(style_groups::BACKDROP));
768        assert!(dirty.style.intersects(style_groups::LAYER));
769        assert!(!dirty.style.intersects(style_groups::FILTER));
770        assert!(
771            cached
772                .style
773                .as_ref()
774                .is_some_and(|s| s.backdrop_filter.is_some())
775        );
776
777        let (dirty, _) = cached.merge_delta(Props::default(), &[], &["backdropFilter".into()]);
778        assert!(dirty.style.intersects(style_groups::BACKDROP));
779        assert!(dirty.style.intersects(style_groups::LAYER));
780        assert!(
781            cached
782                .style
783                .as_ref()
784                .is_some_and(|s| s.backdrop_filter.is_none())
785        );
786    }
787
788    /// A `morphFilter` delta dirties MORPH (the `MorphInput` re-stamp — which
789    /// also routes to `apply_transition`) and LAYER (the promotion trigger) —
790    /// never FILTER/BACKDROP/TRANSITION. `styleUnset` re-fires the same
791    /// groups; a malformed value degrades to `None` without aborting the
792    /// containing `Style`.
793    #[test]
794    fn morph_filter_delta_dirties_morph_and_layer() {
795        let mut cached = Props::default();
796        let (dirty, _) = cached.merge_delta(
797            props(serde_json::json!({
798                "style": { "morphFilter": { "key": "a", "name": "crossfade" } }
799            })),
800            &[],
801            &[],
802        );
803        assert!(dirty.style.intersects(style_groups::MORPH));
804        assert!(dirty.style.intersects(style_groups::LAYER));
805        assert!(!dirty.style.intersects(style_groups::FILTER));
806        assert!(!dirty.style.intersects(style_groups::BACKDROP));
807        assert!(!dirty.style.intersects(style_groups::TRANSITION));
808        let morph = cached
809            .style
810            .as_ref()
811            .and_then(|s| s.morph_filter.as_ref())
812            .expect("morph retained");
813        assert_eq!(morph.key, serde_json::json!("a"));
814        assert_eq!(morph.filter.name, "crossfade");
815
816        let (dirty, _) = cached.merge_delta(Props::default(), &[], &["morphFilter".into()]);
817        assert!(dirty.style.intersects(style_groups::MORPH));
818        assert!(dirty.style.intersects(style_groups::LAYER));
819        assert!(
820            cached
821                .style
822                .as_ref()
823                .is_some_and(|s| s.morph_filter.is_none())
824        );
825
826        // Malformed (missing key) degrades to None; the sibling field lives.
827        let s: Style =
828            serde_json::from_str(r#"{ "morphFilter": { "name": "crossfade" }, "opacity": 0.5 }"#)
829                .expect("a bad morphFilter must not abort the style");
830        assert!(s.morph_filter.is_none());
831        assert_eq!(s.opacity.static_val(), Some(0.5));
832    }
833
834    /// Compile-time completeness guard: a `Style` struct literal built from the
835    /// field table must name every field — adding a `Style` field without
836    /// extending `with_style_fields!` fails this with E0063 (missing field).
837    #[test]
838    fn style_field_table_is_complete() {
839        macro_rules! build_full {
840            ($(($f:ident, $name:literal, $g:tt, $ov:ident),)*) => {
841                Style { $($f: None,)* }
842            };
843        }
844        let _style: Style = with_style_fields!(build_full);
845    }
846
847    /// Every table wire name must equal serde's `rename_all = "camelCase"`
848    /// rendering of the field ident, or `unset_field`/the JS delta builder
849    /// would miss the field.
850    #[test]
851    fn style_wire_names_match_serde_rename() {
852        fn camel(s: &str) -> String {
853            let mut out = String::new();
854            let mut up = false;
855            for c in s.chars() {
856                if c == '_' {
857                    up = true;
858                } else if up {
859                    out.extend(c.to_uppercase());
860                    up = false;
861                } else {
862                    out.push(c);
863                }
864            }
865            out
866        }
867        macro_rules! check {
868            ($(($f:ident, $name:literal, $g:tt, $ov:ident),)*) => {
869                $( assert_eq!(camel(stringify!($f)), $name, "table wire name for `{}`", stringify!($f)); )*
870            };
871        }
872        with_style_fields!(check);
873    }
874}