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}