bevy_react/protocol/props.rs
1//! [`Props`] — the content/attribute level of a host element — and its
2//! dirty/event bookkeeping ([`PropsDirty`], [`UpdateEvents`]).
3
4use serde::Deserialize;
5
6use crate::canvas::DrawCmd;
7
8use super::background_image::{AtlasSpec, ImageMode, SourceRect};
9use super::style::{Style, StyleDirty};
10
11/// Props for a host element. Event handlers never cross the boundary — the
12/// reconciler replaces them with booleans (e.g. `onClick: true`) and keeps the
13/// actual function in a JS-side map. Visual styling lives entirely in [`Style`];
14/// the fields here are content/attribute level.
15#[derive(Debug, Clone, Default, Deserialize)]
16#[serde(rename_all = "camelCase")]
17pub struct Props {
18 /// CSS-like layout + visual style, mapped onto `bevy_ui` components.
19 #[serde(default)]
20 pub style: Option<Style>,
21 /// Style overlaid on `style` while the element is hovered. Decoded exactly
22 /// like `style`; applied on the Bevy side from the node's `Interaction`.
23 #[serde(default)]
24 pub hover_style: Option<Style>,
25 /// Style overlaid on `style` (and `hover_style`) while the element is pressed.
26 #[serde(default)]
27 pub press_style: Option<Style>,
28 /// Style overlaid on `style` while the element is focused (currently
29 /// `editableText`). Applied on the Bevy side from the node's focus state, so
30 /// focus styling needs no React round-trip.
31 #[serde(default)]
32 pub focus_style: Option<Style>,
33 /// Whether this element has an `onClick` handler registered in JS.
34 #[serde(default)]
35 pub on_click: bool,
36 /// Whether this element has an `onPointerDown` handler registered in JS.
37 #[serde(default)]
38 pub on_pointer_down: bool,
39 /// Whether this element has an `onPointerMove` handler registered in JS.
40 /// Fires each frame while the pointer is held down (a drag).
41 #[serde(default)]
42 pub on_pointer_move: bool,
43 /// Whether this element has an `onPointerUp` handler registered in JS.
44 #[serde(default)]
45 pub on_pointer_up: bool,
46 /// Whether this element has an `onPointerEnter` handler registered in JS.
47 /// Fires once when the pointer enters the element (hover begins).
48 #[serde(default)]
49 pub on_pointer_enter: bool,
50 /// Whether this element has an `onPointerLeave` handler registered in JS.
51 /// Fires once when the pointer leaves the element (hover ends).
52 #[serde(default)]
53 pub on_pointer_leave: bool,
54
55 // --- controlled scroll (any node with `overflow: scroll`) ---
56 /// Controlled vertical scroll offset (logical px) → `ScrollPosition.y`. On
57 /// update it's pushed into the node only when it diverges from the live offset
58 /// (so a re-render echoing the user's own wheel scroll is a no-op — see
59 /// [`crate::reconcile`]). Each axis is independent; absent leaves it alone.
60 #[serde(default)]
61 pub scroll_top: Option<f32>,
62 /// Controlled horizontal scroll offset (logical px) → `ScrollPosition.x`.
63 #[serde(default)]
64 pub scroll_left: Option<f32>,
65 /// Logical pixels scrolled per mouse-wheel "line" for this container, overriding
66 /// the default. Maps to [`crate::bridge::ScrollStep`]; only scales `Line`-unit
67 /// wheels (trackpad `Pixel` deltas are used raw).
68 #[serde(default)]
69 pub scroll_step: Option<f32>,
70 /// Whether this element has an `onScroll` handler registered in JS. Present →
71 /// the reconciler stamps a [`crate::bridge::ScrollListener`] so the read-back
72 /// system reports offset changes (kept cheap by scoping its `Changed` query to
73 /// that marker, since `ScrollPosition` is a required component of every `Node`).
74 #[serde(default)]
75 pub on_scroll: bool,
76 /// Whether this element has an `onWheel` handler registered in JS. Present →
77 /// the reconciler stamps a [`crate::bridge::WheelListener`] so
78 /// [`crate::scroll::collect_wheel_events`] reports raw wheel deltas over the
79 /// node (any node, unlike `onScroll`, which needs `overflow: scroll`).
80 #[serde(default)]
81 pub on_wheel: bool,
82
83 /// World-anchor binding for an `<anchor>` element: the Bevy entity to follow and
84 /// an optional offset. Present → the reconciler stamps a [`crate::anchor::Anchored`]
85 /// so the per-frame positioning system tracks it. Pure-serde, Bevy-free.
86 #[serde(default)]
87 pub anchor: Option<crate::anchor::Anchor>,
88
89 // --- `image` element attributes ---
90 /// Asset path for an `image`, resolved by Bevy's `AssetServer` (relative to
91 /// the app's `assets/` folder). Absent → a solid-color image (see `tint`).
92 #[serde(default)]
93 pub src: Option<String>,
94 /// Tint multiplied with the image (hex); also the fill of a `src`-less image.
95 #[serde(default)]
96 pub tint: Option<String>,
97 /// Flip the image along its x-axis.
98 #[serde(default)]
99 pub flip_x: bool,
100 /// Flip the image along its y-axis.
101 #[serde(default)]
102 pub flip_y: bool,
103 /// How the image fits its box: the keyword `"auto"`/`"stretch"`, or a
104 /// `type`-tagged object for 9-slice (`"sliced"`) / `"tiled"` scaling.
105 #[serde(default)]
106 pub image_mode: Option<ImageMode>,
107 /// Source sub-rect of the texture to display, in source-texture pixels.
108 /// Maps to `ImageNode.rect`. With `atlas`, it offsets from the atlas cell's
109 /// top-left corner.
110 #[serde(default)]
111 pub source_rect: Option<SourceRect>,
112 /// Treat `src` as a uniform sprite-sheet grid and select one cell. Maps to
113 /// `ImageNode.texture_atlas` (builds/caches a `TextureAtlasLayout`).
114 #[serde(default)]
115 pub atlas: Option<AtlasSpec>,
116 /// Which box of the node the image fills: `"content"` | `"padding"`
117 /// (default) | `"border"`. Maps to `ImageNode.visual_box`.
118 #[serde(default)]
119 pub visual_box: Option<String>,
120
121 // --- `canvas` element attributes ---
122 /// The declarative display list for a `canvas` element: an ordered batch of
123 /// vector draw commands (the recorded form of an HTML-canvas-like
124 /// `ctx.moveTo/lineTo/…` session). Present → the retained surface is
125 /// **cleared and the list replayed** (raster state reset first).
126 /// `Some(vec![])` clears the canvas; absent leaves the retained pixels.
127 /// Imperative (accumulating) drawing rides [`super::op::Op::Draw`] instead.
128 #[serde(default)]
129 pub draw: Option<Vec<DrawCmd>>,
130 /// Whether this element has an `onResize` handler registered in JS. Cached
131 /// only so the delta stays truthful — `"resize"` events are **not** gated
132 /// on it (the JS runtime consumes them unconditionally, to replay a
133 /// declarative painter and keep the canvas handle's size fresh).
134 #[serde(default)]
135 pub on_resize: bool,
136
137 // --- `portal` element attribute ---
138 /// The render-target name a `portal` element displays. The reconciler stamps
139 /// a `crate::portal::RPortal` carrying it; the binding system points the
140 /// node's `ImageNode` at the texture the app registered under this name (or a
141 /// transparent placeholder until it appears). Pure-serde, Bevy-free.
142 #[serde(default)]
143 pub target: Option<String>,
144
145 // --- `svg` element + shape-child attributes ---
146 /// The folded SVG attributes of a shape child (`<circle>`/`<rect>`/…)
147 /// inside an `<svg>` element. The JS side folds the flat JSX attrs into
148 /// this one object; on update it **replaces atomically** (see
149 /// [`Props::merge_delta`]).
150 #[serde(default)]
151 pub shape: Option<crate::svg::ShapeAttrs>,
152 /// The `<svg>` element's `viewBox` (`"minX minY width height"`), parsed
153 /// at the serde boundary. (`rename_all = "camelCase"` yields exactly the
154 /// `viewBox` wire name — pinned by a test.)
155 #[serde(default, deserialize_with = "crate::svg::de_view_box")]
156 pub view_box: Option<crate::svg::ViewBox>,
157
158 // --- `editableText` element attributes ---
159 /// The controlled text value of an `editableText`. Seeds the field on create;
160 /// on update it's pushed into the widget only when it diverges from the live
161 /// buffer (so normal typing is never clobbered — see [`crate::reconcile`]).
162 #[serde(default)]
163 pub value: Option<String>,
164 /// Maximum number of characters an `editableText` accepts.
165 #[serde(default)]
166 pub max_length: Option<usize>,
167 /// Whether an `editableText` accepts newlines (multi-line input).
168 #[serde(default)]
169 pub multiline: bool,
170 /// Whether this element has an `onChange` handler registered in JS.
171 #[serde(default)]
172 pub on_change: bool,
173 /// Focus an `editableText` when it mounts (inserts `AutoFocus`).
174 #[serde(default)]
175 pub autofocus: bool,
176 /// Controlled selection anchor, a UTF-8 **byte** offset into the value.
177 /// When `selection_start`/`selection_end` diverge from the live selection
178 /// they're pushed into the widget (see [`crate::reconcile`]).
179 #[serde(default)]
180 pub selection_start: Option<usize>,
181 /// Controlled selection focus, a UTF-8 **byte** offset into the value.
182 #[serde(default)]
183 pub selection_end: Option<usize>,
184 /// Accessible name announced to assistive tech (sets the a11y node's label).
185 #[serde(default)]
186 pub aria_label: Option<String>,
187 /// Whether this element has an `onSelect` handler registered in JS.
188 #[serde(default)]
189 pub on_select: bool,
190 /// Whether this element has an `onFocus` handler registered in JS.
191 #[serde(default)]
192 pub on_focus: bool,
193 /// Whether this element has an `onBlur` handler registered in JS.
194 #[serde(default)]
195 pub on_blur: bool,
196}
197
198/// Which parts of a [`Props`] a delta update touched; drives which of the
199/// reconciler's `apply_*` helpers run. Style granularity lives in
200/// [`StyleDirty`]; the other flags are per prop group.
201#[derive(Debug, Clone, Copy, Default)]
202pub struct PropsDirty {
203 /// Style groups touched via `style` / `style_unset`.
204 pub style: StyleDirty,
205 /// `hoverStyle` set or unset.
206 pub hover_style: bool,
207 /// `pressStyle` set or unset.
208 pub press_style: bool,
209 /// `focusStyle` set or unset.
210 pub focus_style: bool,
211 /// Any of `onClick` / `onPointerDown|Move|Up|Enter|Leave` toggled.
212 pub pointer: bool,
213 /// `onScroll` toggled.
214 pub scroll_listener: bool,
215 /// `onWheel` toggled.
216 pub wheel: bool,
217 /// `scrollStep` changed.
218 pub scroll_step: bool,
219 /// `anchor` changed.
220 pub anchor: bool,
221 /// Any `image` attribute (`src`/`tint`/`flipX`/`flipY`/`imageMode`/
222 /// `sourceRect`/`atlas`/`visualBox`) changed.
223 pub image: bool,
224 /// `target` (portal/surface binding) changed.
225 pub target: bool,
226 /// `shape` (an SVG shape child's folded attrs) changed.
227 pub shape: bool,
228 /// `viewBox` (an `<svg>` element's user-unit rect) changed.
229 pub view_box: bool,
230 /// Any `editableText` handler flag (`onChange`/`onSelect`/`onFocus`/
231 /// `onBlur`) toggled.
232 pub editable_handlers: bool,
233 /// `ariaLabel` changed.
234 pub aria_label: bool,
235}
236
237impl PropsDirty {
238 /// Whether the [`crate::bridge::StyleVariants`] component needs rebuilding:
239 /// its `base` mirrors `style`, so any style-field change counts too.
240 pub fn any_style_variant(&self) -> bool {
241 self.style.any() || self.hover_style || self.press_style || self.focus_style
242 }
243}
244
245/// The "act now" props of an update, split from the retained state: pushed
246/// into the live widget once and never stored, so an unrelated later delta
247/// can't replay them (re-push a controlled value, re-clone a canvas display
248/// list). Absent fields mean "no event", exactly like the pre-delta protocol.
249#[derive(Debug, Default)]
250pub struct UpdateEvents {
251 /// Controlled `editableText` value to push (when diverging).
252 pub value: Option<String>,
253 /// Controlled selection anchor (UTF-8 byte offset).
254 pub selection_start: Option<usize>,
255 /// Controlled selection focus (UTF-8 byte offset).
256 pub selection_end: Option<usize>,
257 /// Controlled vertical scroll offset.
258 pub scroll_top: Option<f32>,
259 /// Controlled horizontal scroll offset.
260 pub scroll_left: Option<f32>,
261 /// A `<canvas>` display list to clear + replay.
262 pub draw: Option<Vec<DrawCmd>>,
263}
264
265/// Test helper shared by the protocol submodules' unit tests: decode a
266/// `Props` from a JSON value, panicking on malformed input.
267#[cfg(test)]
268pub(crate) fn props_from_json(json: serde_json::Value) -> Props {
269 serde_json::from_value(json).expect("valid props")
270}