bevy_react/plugin.rs
1//! The public Bevy plugin: wires the JS thread, channels, UI root, and hot
2//! reload into a consumer's `App`.
3
4use std::path::PathBuf;
5
6use crate::animations::{
7 AnimationCommand, AnimationSet, AnimationSettled, ReactUiAnimationsPlugin,
8};
9use bevy::asset::embedded_asset;
10use bevy::prelude::*;
11use bevy::window::CustomCursorImage;
12
13use crate::filter::{FilterMaterial, FilterMaterialCache, init_filter_assets};
14
15use crate::bridge::{JsBridge, OpReceiver, OutboundResource, OutboundSender};
16use crate::event::ReactEventRegistry;
17use crate::host::{self, HostConfig, HostSenders};
18use crate::message::{ReactMessage, ReactRegistry};
19use crate::protocol::{Op, Outbound};
20use crate::reconcile::{
21 OpApplyStats, apply_interaction_styles, apply_js_ops, apply_pending_selections,
22 apply_surface_interaction_styles, collect_canvas_resize_events, collect_hover_events,
23 collect_pointer_events, collect_scroll_events, collect_surface_clicks,
24 collect_surface_hover_events, collect_surface_pointer_events, collect_ui_events,
25 on_focus_gained, on_focus_lost, on_text_edit_change, sync_editable_a11y,
26};
27use crate::request::{RawRequest, ReactRequestRegistry, RequestReceiver, dispatch_react_requests};
28
29/// Whether the React UI currently owns the mouse pointer. Refreshed every frame
30/// in [`PointerCaptureSet`]; world-input systems (a 3D camera controller, picking,
31/// …) should consult it and ignore the mouse when it reports captured, so a UI
32/// drag or click doesn't also drive the scene.
33///
34/// Order such a system after the set to read the current frame's state:
35/// ```no_run
36/// # use bevy::prelude::*;
37/// # use bevy_react::PointerCaptureSet;
38/// # fn orbit_camera() {}
39/// # let mut app = App::new();
40/// app.add_systems(Update, orbit_camera.after(PointerCaptureSet));
41/// ```
42#[derive(Resource, Default, Debug, Clone, Copy)]
43pub struct PointerCapture {
44 /// A bevy-react element is being dragged (an `onPointer*` press is in
45 /// progress). Stays true for the whole gesture — even after the cursor leaves
46 /// the element's bounds — until the button is released.
47 pub dragging: bool,
48 /// The pointer is over an interactive UI element (its `Interaction` is
49 /// `Hovered` or `Pressed`).
50 pub over_ui: bool,
51}
52
53impl PointerCapture {
54 /// The UI owns the current pointer input; world systems should ignore the
55 /// mouse. True while dragging a UI element or while over interactive UI.
56 pub fn is_captured(&self) -> bool {
57 self.dragging || self.over_ui
58 }
59}
60
61/// System set in which [`PointerCapture`] is refreshed each frame. Order your
62/// world-input systems `.after(PointerCaptureSet)` so they see this frame's state.
63#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
64pub struct PointerCaptureSet;
65
66/// Adds a React-driven `bevy_ui` layer to a Bevy `App`.
67///
68/// Point it at a built JS bundle (see the `bevy-react` npm package). The plugin
69/// spawns the dedicated JS thread, applies the reconciler's ops to the ECS,
70/// reports interactions back to React, and — unless disabled — hot reloads the
71/// app when the bundle changes on disk.
72pub struct ReactUiPlugin {
73 bundle: PathBuf,
74 hot_reload: bool,
75 animations: bool,
76 default_font: Option<PathBuf>,
77 named_fonts: Vec<(String, PathBuf)>,
78 custom_cursors: Vec<(String, PathBuf, (u16, u16))>,
79}
80
81impl ReactUiPlugin {
82 /// Create the plugin for the given built app bundle (`app.js`). The build
83 /// emits a `vendor.js` beside it (react + the bevy-react runtime, loaded once);
84 /// both must exist. Hot reload (React Fast Refresh — edits preserve component
85 /// state) and the Reanimated-style animations engine are enabled by default.
86 ///
87 /// The plugin does **not** spawn a camera — `bevy_ui` needs one to render, so
88 /// your app must provide it (a `Camera2d`, or any camera that renders UI).
89 pub fn new(bundle: impl Into<PathBuf>) -> Self {
90 Self {
91 bundle: bundle.into(),
92 hot_reload: true,
93 animations: true,
94 default_font: None,
95 named_fonts: Vec::new(),
96 custom_cursors: Vec::new(),
97 }
98 }
99
100 /// Enable/disable watching the bundle and hot reloading on change.
101 pub fn hot_reload(mut self, yes: bool) -> Self {
102 self.hot_reload = yes;
103 self
104 }
105
106 /// Enable/disable the bundled [`ReactUiAnimationsPlugin`] (the `Animated.node`
107 /// / shared-value engine). On by default; disable to drop it entirely — the
108 /// `op_animate` op stays registered but its commands are discarded.
109 pub fn with_animations(mut self, yes: bool) -> Self {
110 self.animations = yes;
111 self
112 }
113
114 /// Set the app-wide default font, loaded via the `AssetServer` (path relative
115 /// to your `AssetPlugin.file_path`, e.g. `"fonts/Roboto.ttf"`). Every `<text>`
116 /// run uses it unless its style names another family via [`Self::font`].
117 pub fn default_font(mut self, path: impl Into<PathBuf>) -> Self {
118 self.default_font = Some(path.into());
119 self
120 }
121
122 /// Register a named font family. React selects it per element with
123 /// `style={{ fontFamily: name }}`; the path is loaded via the `AssetServer`
124 /// (relative to your `AssetPlugin.file_path`).
125 pub fn font(mut self, name: impl Into<String>, path: impl Into<PathBuf>) -> Self {
126 self.named_fonts.push((name.into(), path.into()));
127 self
128 }
129
130 /// Register a named custom **image** cursor. React selects it per element with
131 /// `style={{ cursor: name }}` (any name that isn't a built-in cursor keyword);
132 /// the image at `path` is loaded via the `AssetServer` (relative to your
133 /// `AssetPlugin.file_path`), and `hotspot` is the click-point pixel (top-left
134 /// origin) within it. The cursor analogue of [`Self::font`].
135 pub fn cursor(
136 mut self,
137 name: impl Into<String>,
138 path: impl Into<PathBuf>,
139 hotspot: (u16, u16),
140 ) -> Self {
141 self.custom_cursors
142 .push((name.into(), path.into(), hotspot));
143 self
144 }
145}
146
147impl Plugin for ReactUiPlugin {
148 fn build(&self, app: &mut App) {
149 // The `filter` style's shader, embedded so it ships with the crate (no
150 // `assets/` folder needed by consumers). The `UiMaterialPlugin` registers
151 // the `FilterMaterial` asset + render pipeline; `init_filter_assets`
152 // creates the shared white pixel for solid-color filtered nodes. Gated on
153 // a render pipeline being present (the canonical `DefaultPlugins`-first
154 // setup), since `embedded_asset!`/`UiMaterialPlugin` need the asset + render
155 // infrastructure — a headless `App` with neither (e.g. wiring-only tests)
156 // simply skips it.
157 if app.is_plugin_added::<bevy::render::RenderPlugin>() {
158 embedded_asset!(app, "filter.wgsl");
159 app.add_plugins(UiMaterialPlugin::<FilterMaterial>::default())
160 .init_resource::<FilterMaterialCache>()
161 .add_systems(Startup, init_filter_assets);
162 }
163
164 // Channels: op batches, app messages, requests, and animation commands flow
165 // JS -> Bevy (crossbeam, same on every target). The Bevy -> JS direction (a
166 // single `Outbound` stream plus, on native, reload signals) is owned by the
167 // target's host, which returns its sender below.
168 // TODO(review): all of these are UNBOUNDED — there's no backpressure. A system that
169 // `events.send`s every frame while the JS side consumes slowly (or not at all) grows
170 // the outbound queue without bound. Consider bounded channels with an explicit
171 // drop/coalesce policy before this is "production".
172 let (ops_tx, ops_rx) = crossbeam_channel::unbounded::<Vec<Op>>();
173 let (emit_tx, emit_rx) = crossbeam_channel::unbounded::<ReactMessage>();
174 let (request_tx, request_rx) = crossbeam_channel::unbounded::<RawRequest>();
175 let (anim_tx, anim_rx) = crossbeam_channel::unbounded::<AnimationCommand>();
176
177 // Spawn/install the target's JS host: native runs an embedded V8 isolate on
178 // a dedicated thread (fed from disk, with hot reload); web runs React in the
179 // browser's own engine. The host owns the Bevy->JS transport and returns the
180 // sender every outbound producer writes to. It starts before `setup` builds
181 // the root, so the first ops simply queue until then.
182 let outbound_tx = host::spawn(
183 app,
184 HostConfig {
185 bundle: self.bundle.clone(),
186 hot_reload: self.hot_reload,
187 },
188 HostSenders {
189 ops: ops_tx,
190 emit: emit_tx,
191 request: request_tx,
192 anim: anim_tx,
193 },
194 );
195
196 app.insert_resource(BridgeChannels {
197 ops_rx: Some(ops_rx),
198 outbound_tx: outbound_tx.clone(),
199 })
200 // A standalone outbound handle for the request dispatcher and the
201 // `ReactEvents` param, available before `setup` builds `JsBridge`.
202 .insert_resource(OutboundResource(outbound_tx))
203 .insert_resource(EmitReceiver(emit_rx))
204 .insert_resource(RequestReceiver(request_rx))
205 .insert_resource(ReactUiConfig {
206 default_font: self.default_font.clone(),
207 named_fonts: self.named_fonts.clone(),
208 custom_cursors: self.custom_cursors.clone(),
209 })
210 .init_resource::<ReactRegistry>()
211 .init_resource::<ReactRequestRegistry>()
212 .init_resource::<ReactEventRegistry>()
213 .init_resource::<PointerCapture>()
214 .init_resource::<OpApplyStats>()
215 .init_resource::<crate::ui_map::AtlasLayoutCache>()
216 .init_resource::<Fonts>()
217 .init_resource::<crate::cursor::CustomCursors>()
218 // The offscreen render-target ("portal") registry and its shared blank
219 // placeholder texture, created before the first portal can mount.
220 .init_resource::<crate::portal::RenderTargets>()
221 .add_systems(Startup, crate::portal::init_portal_placeholder)
222 // The `<surface>` registry (UI subtrees rendered into offscreen textures)
223 // and its single virtual pointer for in-world clicks.
224 .init_resource::<crate::surface::Surfaces>()
225 .add_systems(Startup, crate::surface::init_surface_pointer)
226 .add_systems(Startup, setup)
227 .add_systems(
228 PreUpdate,
229 (dispatch_react_messages, dispatch_react_requests),
230 )
231 // Drive the surface virtual pointer (cursor → mesh UV → image render
232 // target) before `bevy_picking` processes inputs, so the offscreen UI is
233 // hit-tested with this frame's cursor.
234 .add_systems(
235 PreUpdate,
236 crate::surface::drive_surface_pointer
237 .before(bevy::picking::PickingSystems::ProcessInput),
238 )
239 .add_systems(
240 Update,
241 (
242 apply_js_ops,
243 collect_ui_events,
244 // Forward window-global keystrokes to React as built-in named
245 // events (`onKeyDown`/`onKeyUp`). Node-independent: reads Bevy's
246 // `KeyboardInput` messages directly, no reconciler state needed.
247 crate::keyboard::collect_keyboard_events,
248 // Emit `pointerEnter`/`pointerLeave` from `Interaction` transitions
249 // (same signal as hover styling), for nodes with those handlers.
250 collect_hover_events,
251 collect_pointer_events.in_set(PointerCaptureSet),
252 // Wheel-scroll any `overflow: scroll` node under the cursor, and
253 // deliver raw wheel deltas to any `onWheel` node. Both in the same
254 // set, after `collect_pointer_events`, so their `PointerCapture::over_ui`
255 // claim survives (that system *assigns* `over_ui`) and world systems
256 // (ordered `.after(PointerCaptureSet)`) see it. (A sub-tuple: the outer
257 // tuple is at Bevy's arity limit.)
258 (
259 crate::scroll::apply_scroll
260 .in_set(PointerCaptureSet)
261 .after(collect_pointer_events),
262 crate::scroll::collect_wheel_events
263 .in_set(PointerCaptureSet)
264 .after(collect_pointer_events),
265 ),
266 // Ease `ScrollPosition` toward the target the controlled write
267 // (`apply_js_ops`) and the wheel (`PointerCaptureSet`) set this frame.
268 // Runs after both so it eases toward the freshest target.
269 crate::transition::drive_scroll_transition
270 .after(apply_js_ops)
271 .after(PointerCaptureSet),
272 // Report scroll-offset changes (wheel, controlled write, or an eased
273 // frame) to JS for any node with an `onScroll` handler. After the ease
274 // so it sees the moved offset; after the op drain so a controlled write
275 // is already seeded into the dedup map (no echo).
276 collect_scroll_events
277 .after(crate::transition::drive_scroll_transition)
278 .after(PointerCaptureSet)
279 .after(apply_js_ops),
280 // After the op drain so this frame's `StyleVariants` writes are
281 // visible; the ordering forces a command sync point first.
282 apply_interaction_styles.after(apply_js_ops),
283 // Ease `transform`/`opacity`/`backgroundColor` toward the target the
284 // style appliers just wrote. After `apply_interaction_styles` (and
285 // thus the op drain) so the eased value lands last and a coincident
286 // re-render's snap never wins.
287 crate::transition::drive_transitions.after(apply_interaction_styles),
288 // World-anchored overlays reposition after the op drain so they
289 // override this frame's static `left`/`top`.
290 crate::anchor::position_anchored_nodes.after(apply_js_ops),
291 // Repaint `<canvas>` textures after their surfaces/sizes update,
292 // and report layout resizes to JS (the surface just cleared — so
293 // the app / the runtime's declarative replay redraws). Both read
294 // last frame's `ComputedNode`. The cursor driver rides here too —
295 // it also reads last frame's `ComputedNode` after the op drain
296 // (freshly-stamped `NodeCursor`s visible), and this sub-tuple keeps
297 // the outer tuple under Bevy's arity limit.
298 (
299 crate::canvas::update_canvas_surfaces.after(apply_js_ops),
300 collect_canvas_resize_events.after(apply_js_ops),
301 crate::cursor::drive_cursor_icon.after(apply_js_ops),
302 ),
303 // Bind `<portal>` nodes to their render-target textures after the
304 // op drain (so a freshly-spawned portal binds the same frame), then
305 // drive resolution + the snapshot camera lifecycle.
306 crate::portal::bind_portals.after(apply_js_ops),
307 crate::portal::drive_render_targets.after(crate::portal::bind_portals),
308 // Bind `<surface>` roots to their offscreen UI cameras after the op
309 // drain (so a freshly-mounted surface binds the same frame), then
310 // drive the snapshot camera lifecycle.
311 crate::surface::bind_surfaces.after(apply_js_ops),
312 crate::surface::drive_surfaces.after(crate::surface::bind_surfaces),
313 // Surface interaction: turn the virtual pointer's picking events on
314 // the offscreen subtree into `onClick`/`onPointer*` + hover/press
315 // styling. The picking events are produced in `PreUpdate`, so these
316 // read this frame's events.
317 collect_surface_clicks,
318 collect_surface_pointer_events,
319 collect_surface_hover_events,
320 apply_surface_interaction_styles,
321 ),
322 );
323
324 // `editableText` edits arrive as Bevy's `TextEditChange` trigger; an observer
325 // turns real changes into `"change"` and selection moves into `"select"` UI
326 // events. Two more observers bridge focus gain/loss to `"focus"`/`"blur"`.
327 app.add_observer(on_text_edit_change);
328 app.add_observer(on_focus_gained);
329 app.add_observer(on_focus_lost);
330
331 // Controlled-selection writes and the a11y value sync run after Bevy's
332 // text-edit pass (`EditableTextSystems`) so they see this frame's applied
333 // edits and resolve byte offsets against the current text.
334 app.add_systems(
335 PostUpdate,
336 (apply_pending_selections, sync_editable_a11y).after(bevy::text::EditableTextSystems),
337 );
338
339 // The animations engine is a separate plugin (its crate can't depend on
340 // this one). We add it and, as the only crate that sees both sides, order
341 // its `Apply` set after `apply_js_ops` so per-frame animation writes win
342 // over this frame's static style. Disabled → `anim_rx` drops here and
343 // `op_animate` sends are discarded.
344 if self.animations {
345 app.add_plugins(ReactUiAnimationsPlugin::new(anim_rx))
346 .configure_sets(Update, AnimationSet::Apply.after(apply_js_ops))
347 // Completion callbacks: settlements the engine reports (once per
348 // token-tagged driver, not per frame) go out to JS.
349 .add_systems(Update, forward_animation_settled.after(AnimationSet::Tick));
350 }
351 }
352}
353
354/// Forward the animation engine's [`AnimationSettled`] messages to JS as
355/// [`Outbound::AnimationFinished`], resolving each to its completion callback.
356/// The engine crate can't depend on this one, so the bridging happens here.
357fn forward_animation_settled(
358 mut settled: MessageReader<AnimationSettled>,
359 outbound: Res<OutboundResource>,
360) {
361 for s in settled.read() {
362 let _ = outbound.0.send(Outbound::AnimationFinished {
363 id: s.id,
364 token: s.token,
365 finished: s.finished,
366 });
367 }
368}
369
370/// Marker for the UI root entity (reconciler node id 0 / `ROOT_ID`).
371#[derive(Component)]
372struct UiRoot;
373
374/// Plugin configuration read by the startup system.
375#[derive(Resource)]
376struct ReactUiConfig {
377 default_font: Option<PathBuf>,
378 named_fonts: Vec<(String, PathBuf)>,
379 custom_cursors: Vec<(String, PathBuf, (u16, u16))>,
380}
381
382/// Fonts loaded from the plugin config, resolved to handles at startup. The
383/// default backs every `<text>` run; named entries are selected per element via
384/// the `fontFamily` style prop. Empty (unconfigured) → Bevy's built-in font.
385#[derive(Resource, Default)]
386pub struct Fonts {
387 pub default: Option<Handle<Font>>,
388 pub named: std::collections::HashMap<String, Handle<Font>>,
389}
390
391/// Carries the Bevy-side channel ends from `build` into `setup`.
392#[derive(Resource)]
393struct BridgeChannels {
394 ops_rx: Option<OpReceiver>,
395 outbound_tx: OutboundSender,
396}
397
398/// Receives app messages emitted by the React app (`emit(name, value)`).
399#[derive(Resource)]
400struct EmitReceiver(crossbeam_channel::Receiver<ReactMessage>);
401
402/// The single consumption point for React-emitted messages. Drains the channel
403/// each frame and routes every message to its registered typed payload via
404/// [`ReactRegistry`], triggering it for observers. Runs in `PreUpdate` so the
405/// triggers land before consumer `Update` systems run the same frame.
406fn dispatch_react_messages(
407 rx: Res<EmitReceiver>,
408 registry: Res<ReactRegistry>,
409 mut commands: Commands,
410) {
411 while let Ok(msg) = rx.0.try_recv() {
412 registry.dispatch(msg, &mut commands);
413 }
414}
415
416fn setup(
417 mut commands: Commands,
418 mut channels: ResMut<BridgeChannels>,
419 config: Res<ReactUiConfig>,
420 assets: Res<AssetServer>,
421) {
422 // Load configured fonts into the `Fonts` resource before the first
423 // `apply_js_ops` (Update) creates any text.
424 commands.insert_resource(Fonts {
425 default: config.default_font.as_ref().map(|p| assets.load(p.clone())),
426 named: config
427 .named_fonts
428 .iter()
429 .map(|(name, path)| (name.clone(), assets.load(path.clone())))
430 .collect(),
431 });
432 // Likewise load configured custom image cursors into the registry `drive_cursor_icon`
433 // resolves a `cursor` name against.
434 commands.insert_resource(crate::cursor::CustomCursors(
435 config
436 .custom_cursors
437 .iter()
438 .map(|(name, path, hotspot)| {
439 (
440 name.clone(),
441 CustomCursorImage {
442 handle: assets.load(path.clone()),
443 hotspot: *hotspot,
444 ..default()
445 },
446 )
447 })
448 .collect(),
449 ));
450
451 // The root container: a full-window flex column the reconciler appends
452 // top-level children into (it is reconciler node id 0). Children stack from
453 // the top, horizontally centered.
454 let root = commands
455 .spawn((
456 Node {
457 width: Val::Percent(100.0),
458 height: Val::Percent(100.0),
459 flex_direction: FlexDirection::Column,
460 justify_content: JustifyContent::FlexStart,
461 align_items: AlignItems::Center,
462 row_gap: Val::Px(16.0),
463 ..default()
464 },
465 UiRoot,
466 ))
467 .id();
468
469 // The shared overlay container for world-anchored nodes (`Anchored.node`).
470 // `position_anchored_nodes` reparents every anchored overlay under this so it lives
471 // in its own hierarchy and never inflates an app container's flex layout or
472 // scrollable `content_size`. Zero-size at the window origin (absolute, left/top 0)
473 // with default `Overflow::visible`, so it neither clips its children nor intercepts
474 // pointer input; anchored nodes position themselves relative to its (0,0) corner.
475 // Spawned as the root's first child so the app subtree (appended later via ops)
476 // renders above it — add a `GlobalZIndex` here to lift overlays above app content.
477 commands.spawn((
478 Node {
479 position_type: PositionType::Absolute,
480 left: Val::Px(0.0),
481 top: Val::Px(0.0),
482 width: Val::Px(0.0),
483 height: Val::Px(0.0),
484 ..default()
485 },
486 crate::anchor::AnchorLayer,
487 ChildOf(root),
488 ));
489
490 let ops_rx = channels.ops_rx.take().expect("setup runs once");
491 commands.insert_resource(JsBridge::new(ops_rx, channels.outbound_tx.clone(), root));
492}