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::bridge::{JsBridge, OpReceiver, OutboundResource, OutboundSender};
14use crate::event::ReactEventRegistry;
15use crate::host::{self, HostConfig, HostSenders};
16use crate::message::{ReactAppExt, ReactMessage, ReactRegistry};
17use crate::protocol::{op::Op, outbound::Outbound};
18use crate::reconcile::{
19 OpApplyStats, apply_interaction_styles, apply_js_ops, apply_pending_selections,
20 apply_surface_interaction_styles, collect_canvas_resize_events, collect_hover_events,
21 collect_pointer_events, collect_scroll_events, collect_surface_clicks,
22 collect_surface_hover_events, collect_surface_pointer_events, collect_ui_events,
23 on_focus_gained, on_focus_lost, on_text_edit_change, sync_editable_a11y,
24};
25use crate::request::{RawRequest, ReactRequestRegistry, RequestReceiver, dispatch_react_requests};
26
27/// Whether the React UI currently owns the mouse pointer. Refreshed every frame
28/// in [`PointerCaptureSet`]; world-input systems (a 3D camera controller, picking,
29/// …) should consult it and ignore the mouse when it reports captured, so a UI
30/// drag or click doesn't also drive the scene.
31///
32/// Order such a system after the set to read the current frame's state:
33/// ```no_run
34/// # use bevy::prelude::*;
35/// # use bevy_react::PointerCaptureSet;
36/// # fn orbit_camera() {}
37/// # let mut app = App::new();
38/// app.add_systems(Update, orbit_camera.after(PointerCaptureSet));
39/// ```
40#[derive(Resource, Default, Debug, Clone, Copy)]
41pub struct PointerCapture {
42 /// A bevy-react element is being dragged (an `onPointer*` press is in
43 /// progress). Stays true for the whole gesture — even after the cursor leaves
44 /// the element's bounds — until the button is released.
45 pub dragging: bool,
46 /// The pointer is over an interactive UI element (its `Interaction` is
47 /// `Hovered` or `Pressed`).
48 pub over_ui: bool,
49 /// The UI consumed this frame's wheel input: a scroll container actually
50 /// moved, an `onWheel` listener claimed the delta, or an overlay (devtools
51 /// pick mode) owns the pointer. Unlike `over_ui`, merely hovering
52 /// interactive UI does not set this — a world wheel-consumer (a zoom
53 /// camera) can gate on it alone to keep zooming over pass-through elements.
54 pub wheel_captured: bool,
55}
56
57impl PointerCapture {
58 /// The UI owns the current pointer input; world systems should ignore the
59 /// mouse. True while dragging a UI element, while over interactive UI, or
60 /// when the UI consumed this frame's wheel. Coarse by design — consumers
61 /// wanting finer behavior (e.g. a camera that zooms over pass-through UI)
62 /// read the individual fields instead.
63 pub fn is_captured(&self) -> bool {
64 self.dragging || self.over_ui || self.wheel_captured
65 }
66}
67
68/// System set in which [`PointerCapture`] is refreshed each frame. Order your
69/// world-input systems `.after(PointerCaptureSet)` so they see this frame's state.
70#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
71pub struct PointerCaptureSet;
72
73/// Adds a React-driven `bevy_ui` layer to a Bevy `App`.
74///
75/// Point it at a built JS bundle (see the `bevy-react` npm package). The plugin
76/// spawns the dedicated JS thread, applies the reconciler's ops to the ECS,
77/// reports interactions back to React, and — unless disabled — hot reloads the
78/// app when the bundle changes on disk. In dev builds it also enables the
79/// devtools inspector (toggled with `F12`; see [`Self::devtools`]).
80///
81/// Requires Bevy's `AssetPlugin` (satisfied by `DefaultPlugins`): `build`
82/// registers the [`SvgDocument`](crate::svg::SvgDocument) asset + loader, so a
83/// headless/minimal app must add `AssetPlugin` (plus its task-pool
84/// prerequisites, e.g. via `MinimalPlugins`) before this plugin.
85pub struct ReactUiPlugin {
86 bundle: PathBuf,
87 hot_reload: bool,
88 animations: bool,
89 default_font: Option<PathBuf>,
90 named_fonts: Vec<(String, PathBuf)>,
91 custom_cursors: Vec<(String, PathBuf, (u16, u16))>,
92 #[cfg(feature = "devtools")]
93 devtools: crate::devtools::DevtoolsConfig,
94}
95
96impl ReactUiPlugin {
97 /// Create the plugin for the given built app bundle (`app.js`). The build
98 /// emits a `vendor.js` beside it (react + the bevy-react runtime, loaded once);
99 /// both must exist. Hot reload (React Fast Refresh — edits preserve component
100 /// state) and the Reanimated-style animations engine are enabled by default.
101 ///
102 /// The plugin does **not** spawn a camera — `bevy_ui` needs one to render, so
103 /// your app must provide it (a `Camera2d`, or any camera that renders UI).
104 pub fn new(bundle: impl Into<PathBuf>) -> Self {
105 Self {
106 bundle: bundle.into(),
107 hot_reload: true,
108 animations: true,
109 default_font: None,
110 named_fonts: Vec::new(),
111 custom_cursors: Vec::new(),
112 #[cfg(feature = "devtools")]
113 devtools: Default::default(),
114 }
115 }
116
117 /// Configure the devtools inspector, on by default in dev builds (release
118 /// builds never run it, even when compiled in). Every
119 /// [`DevtoolsConfig`](crate::DevtoolsConfig) field has a default, so
120 /// override only what you need:
121 ///
122 /// ```no_run
123 /// # use bevy::prelude::*;
124 /// # use bevy_react::{DevtoolsConfig, ReactUiPlugin};
125 /// # let mut app = App::new();
126 /// app.add_plugins(ReactUiPlugin::new("ui/dist/app.js").devtools(DevtoolsConfig {
127 /// toggle_key: KeyCode::F1,
128 /// settings_path: Some(".config/devtools.json".into()),
129 /// ..default()
130 /// }));
131 /// // or disable devtools entirely
132 /// app.add_plugins(ReactUiPlugin::new("ui/dist/app.js").devtools(DevtoolsConfig {
133 /// enabled: false,
134 /// ..default()
135 /// }));
136 /// ```
137 #[cfg(feature = "devtools")]
138 pub fn devtools(mut self, config: crate::devtools::DevtoolsConfig) -> Self {
139 self.devtools = config;
140 self
141 }
142
143 /// Enable/disable watching the bundle and hot reloading on change.
144 pub fn hot_reload(mut self, yes: bool) -> Self {
145 self.hot_reload = yes;
146 self
147 }
148
149 /// Enable/disable the bundled [`ReactUiAnimationsPlugin`] (the `Animated.node`
150 /// / shared-value engine). On by default; disable to drop it entirely — the
151 /// `op_animate` op stays registered but its commands are discarded.
152 pub fn with_animations(mut self, yes: bool) -> Self {
153 self.animations = yes;
154 self
155 }
156
157 /// Set the app-wide default font, loaded via the `AssetServer` (path relative
158 /// to your `AssetPlugin.file_path`, e.g. `"fonts/Roboto.ttf"`). Every `<text>`
159 /// run uses it unless its style names another family via [`Self::font`].
160 pub fn default_font(mut self, path: impl Into<PathBuf>) -> Self {
161 self.default_font = Some(path.into());
162 self
163 }
164
165 /// Register a named font family. React selects it per element with
166 /// `style={{ fontFamily: name }}`; the path is loaded via the `AssetServer`
167 /// (relative to your `AssetPlugin.file_path`).
168 pub fn font(mut self, name: impl Into<String>, path: impl Into<PathBuf>) -> Self {
169 self.named_fonts.push((name.into(), path.into()));
170 self
171 }
172
173 /// Register a named custom **image** cursor. React selects it per element with
174 /// `style={{ cursor: name }}` (any name that isn't a built-in cursor keyword);
175 /// the image at `path` is loaded via the `AssetServer` (relative to your
176 /// `AssetPlugin.file_path`), and `hotspot` is the click-point pixel (top-left
177 /// origin) within it. The cursor analogue of [`Self::font`].
178 pub fn cursor(
179 mut self,
180 name: impl Into<String>,
181 path: impl Into<PathBuf>,
182 hotspot: (u16, u16),
183 ) -> Self {
184 self.custom_cursors
185 .push((name.into(), path.into(), hotspot));
186 self
187 }
188}
189
190/// Register the layer/filter shader assets: the importable filter-pass
191/// prelude (`#import bevy_react::filter`, see `layer/filter_prelude.wgsl`)
192/// plus the embedded pass shaders. `load_shader_library!` both embeds the
193/// prelude and loads it as a `Shader`, which registers its
194/// `#define_import_path` with the shader composer.
195///
196/// Split out of [`Plugin::build`]'s render-gated block so asset-capable tests
197/// (see `filters/test_util.rs`) can register the shaders without the render
198/// sub-app; callers must have `AssetPlugin` and the `Shader` asset set up.
199///
200/// Each shader lives next to its loading code — the built-in pass shaders
201/// beside `filters/builtin`'s `ReactFilter::shader` impls, the prelude and
202/// composite beside `layer/render.rs`'s pipelines — and the embedded paths
203/// mirror that layout (this macro roots them at THIS file's directory).
204pub(crate) fn register_layer_shader_assets(app: &mut App) {
205 bevy::shader::load_shader_library!(app, "layer/filter_prelude.wgsl");
206 embedded_asset!(app, "layer/composite.wgsl");
207 embedded_asset!(app, "layer/render/mip_blit.wgsl");
208 embedded_asset!(app, "layer/render/backdrop_blit.wgsl");
209 embedded_asset!(app, "filters/builtin/color_matrix.wgsl");
210 embedded_asset!(app, "filters/builtin/blur.wgsl");
211 embedded_asset!(app, "filters/builtin/bloom.wgsl");
212 embedded_asset!(app, "filters/builtin/chromatic_aberration.wgsl");
213}
214
215impl Plugin for ReactUiPlugin {
216 fn build(&self, app: &mut App) {
217 // Render-side wiring, gated on a render pipeline being present (the
218 // canonical `DefaultPlugins`-first setup), since `embedded_asset!` and the
219 // render sub-app need the asset + render infrastructure — a headless `App`
220 // with neither (e.g. wiring-only tests) simply skips it.
221 if app.is_plugin_added::<bevy::render::RenderPlugin>() {
222 // Layer compositing (see `crate::layer::render`): the capture
223 // pass + composite quad over stock `bevy_ui_render`, public
224 // seams only. Steal window: after Queue, before the stock sort;
225 // prepare after the stock prepares' PrepareBindGroups slot is
226 // irrelevant (disjoint items).
227 register_layer_shader_assets(app);
228 if let Some(render_app) = app.get_sub_app_mut(bevy::render::RenderApp) {
229 use crate::layer::render as lr;
230 use bevy::core_pipeline::schedule::{Core2d, Core2dSystems, Core3d, Core3dSystems};
231 use bevy::render::render_phase::{AddRenderCommand, sort_phase_system};
232 use bevy::render::render_resource::SpecializedRenderPipelines;
233 use bevy::render::{
234 ExtractSchedule, GpuResourceAppExt, Render, RenderStartup, RenderSystems,
235 };
236 use bevy::ui_render::{
237 RenderUiSystems, TransparentUi, extract_ui_camera_view, ui_pass,
238 };
239
240 render_app
241 .init_resource::<lr::ExtractedUiLayers>()
242 .init_resource::<lr::LayerAtlases>()
243 .init_resource::<lr::LayerTextureStore>()
244 .init_gpu_resource::<SpecializedRenderPipelines<lr::LayerCompositePipeline>>()
245 .init_gpu_resource::<lr::LayerCompositeMeta>()
246 .init_gpu_resource::<lr::transform3d::CompositeUniformsMeta>()
247 .init_gpu_resource::<SpecializedRenderPipelines<lr::LayerFilterPipeline>>()
248 .init_gpu_resource::<lr::LayerFilterMeta>()
249 .init_gpu_resource::<SpecializedRenderPipelines<lr::mips::LayerBlitPipeline>>()
250 .init_gpu_resource::<lr::mips::LayerMipMeta>()
251 .init_gpu_resource::<SpecializedRenderPipelines<lr::backdrop::BackdropBlitPipeline>>()
252 .init_gpu_resource::<lr::backdrop::BackdropMeta>()
253 .add_render_command::<TransparentUi, lr::DrawLayerComposite>()
254 .add_systems(
255 RenderStartup,
256 (
257 lr::init_layer_composite_pipeline,
258 lr::init_layer_filter_pipeline,
259 lr::mips::init_layer_blit_pipeline,
260 lr::backdrop::init_backdrop_blit_pipeline,
261 ),
262 )
263 .init_resource::<lr::clip::SwappedClips>()
264 .add_systems(
265 ExtractSchedule,
266 (
267 lr::extract_ui_layers.after(extract_ui_camera_view),
268 // The extract-window clip swap: members'
269 // CalculatedClip carries INTERIOR clips for
270 // exactly the span of the stock UI extraction
271 // sets (the main world is exclusively borrowed
272 // here, so nothing main-world can observe it).
273 // Explicitly bracket every RenderUiSystems set —
274 // there is no umbrella set to hang onto.
275 lr::clip::swap_interior_clips_in
276 .before(RenderUiSystems::ExtractCameraViews)
277 .before(RenderUiSystems::ExtractBoxShadows)
278 .before(RenderUiSystems::ExtractBackgrounds)
279 .before(RenderUiSystems::ExtractImages)
280 .before(RenderUiSystems::ExtractTextureSlice)
281 .before(RenderUiSystems::ExtractBorders)
282 .before(RenderUiSystems::ExtractViewportNodes)
283 .before(RenderUiSystems::ExtractTextBackgrounds)
284 .before(RenderUiSystems::ExtractTextShadows)
285 .before(RenderUiSystems::ExtractText)
286 .before(RenderUiSystems::ExtractCursor)
287 .before(RenderUiSystems::ExtractDebug)
288 .before(RenderUiSystems::ExtractGradient),
289 lr::clip::swap_interior_clips_out
290 .after(RenderUiSystems::ExtractCameraViews)
291 .after(RenderUiSystems::ExtractBoxShadows)
292 .after(RenderUiSystems::ExtractBackgrounds)
293 .after(RenderUiSystems::ExtractImages)
294 .after(RenderUiSystems::ExtractTextureSlice)
295 .after(RenderUiSystems::ExtractBorders)
296 .after(RenderUiSystems::ExtractViewportNodes)
297 .after(RenderUiSystems::ExtractTextBackgrounds)
298 .after(RenderUiSystems::ExtractTextShadows)
299 .after(RenderUiSystems::ExtractText)
300 .after(RenderUiSystems::ExtractCursor)
301 .after(RenderUiSystems::ExtractDebug)
302 .after(RenderUiSystems::ExtractGradient),
303 ),
304 )
305 .add_systems(
306 Render,
307 (
308 lr::redistribute_ui_layers
309 .in_set(RenderSystems::PhaseSort)
310 .before(sort_phase_system::<TransparentUi>),
311 lr::prepare_layer_textures.in_set(RenderSystems::PrepareResources),
312 // Filter staging: allocates/specializes everything
313 // a filter pass needs; `ui_layer_capture_pass`
314 // replays the staged runs after each layer's
315 // capture.
316 lr::prepare_layer_filters
317 .in_set(RenderSystems::PrepareBindGroups)
318 .after(lr::prepare_layer_textures)
319 .before(lr::prepare_layer_composites),
320 // Backdrop staging: snapshot blit + a second
321 // filter run per backdrop layer; the composite
322 // gate reads its `output_valid`.
323 lr::backdrop::prepare_layer_backdrops
324 .in_set(RenderSystems::PrepareBindGroups)
325 .after(lr::prepare_layer_textures)
326 .before(lr::prepare_layer_composites),
327 // Mip staging: after filters (`output_valid` /
328 // `output_index` decided — mips build on the
329 // filter output), before composites (its
330 // bind-group choice reads `mips_valid`).
331 lr::mips::prepare_layer_mips
332 .in_set(RenderSystems::PrepareBindGroups)
333 .after(lr::prepare_layer_filters)
334 .before(lr::prepare_layer_composites),
335 lr::prepare_layer_composites.in_set(RenderSystems::PrepareBindGroups),
336 ),
337 )
338 .add_systems(
339 Core2d,
340 lr::ui_layer_capture_pass
341 .after(Core2dSystems::PostProcess)
342 .before(ui_pass),
343 )
344 .add_systems(
345 Core3d,
346 lr::ui_layer_capture_pass
347 .after(Core3dSystems::PostProcess)
348 .before(ui_pass),
349 );
350 }
351 }
352
353 // Channels: op batches, app messages, requests, and animation commands flow
354 // JS -> Bevy (crossbeam, same on every target). The Bevy -> JS direction (a
355 // single `Outbound` stream plus, on native, reload signals) is owned by the
356 // target's host, which returns its sender below.
357 // TODO(review): all of these are UNBOUNDED — there's no backpressure. A system that
358 // `events.send`s every frame while the JS side consumes slowly (or not at all) grows
359 // the outbound queue without bound. Consider bounded channels with an explicit
360 // drop/coalesce policy before this is "production".
361 let (ops_tx, ops_rx) = crossbeam_channel::unbounded::<Vec<Op>>();
362 // Side channel of per-batch send instants (see `FlushStamps`): feeds the
363 // devtools "pre-apply" timing leg. Stays empty on web (no `Instant`).
364 let (flush_stamps_tx, flush_stamps_rx) =
365 crossbeam_channel::unbounded::<std::time::Instant>();
366 // Side channel of per-batch devtools-origin flags (see `FlushFlags`):
367 // lets applies be attributed to the panel vs the app on every target.
368 let (flush_devtools_tx, flush_devtools_rx) = crossbeam_channel::unbounded::<bool>();
369 let (emit_tx, emit_rx) = crossbeam_channel::unbounded::<ReactMessage>();
370 let (request_tx, request_rx) = crossbeam_channel::unbounded::<RawRequest>();
371 let (anim_tx, anim_rx) = crossbeam_channel::unbounded::<AnimationCommand>();
372
373 // Spawn/install the target's JS host: native runs an embedded V8 isolate on
374 // a dedicated thread (fed from disk, with hot reload); web runs React in the
375 // browser's own engine. The host owns the Bevy->JS transport and returns the
376 // sender every outbound producer writes to. It starts before `setup` builds
377 // the root, so the first ops simply queue until then.
378 let outbound_tx = host::spawn(
379 app,
380 HostConfig {
381 bundle: self.bundle.clone(),
382 hot_reload: self.hot_reload,
383 },
384 HostSenders {
385 ops: ops_tx,
386 flush_stamps: flush_stamps_tx,
387 flush_devtools: flush_devtools_tx,
388 emit: emit_tx,
389 request: request_tx,
390 anim: anim_tx,
391 },
392 );
393 app.insert_resource(crate::reconcile::FlushStamps(flush_stamps_rx));
394 app.insert_resource(crate::reconcile::FlushFlags(flush_devtools_rx));
395
396 app.insert_resource(BridgeChannels {
397 ops_rx: Some(ops_rx),
398 outbound_tx: outbound_tx.clone(),
399 })
400 // A standalone outbound handle for the request dispatcher and the
401 // `ReactEvents` param, available before `setup` builds `JsBridge`.
402 .insert_resource(OutboundResource(outbound_tx))
403 .insert_resource(EmitReceiver(emit_rx))
404 .insert_resource(RequestReceiver(request_rx))
405 .insert_resource(ReactUiConfig {
406 default_font: self.default_font.clone(),
407 named_fonts: self.named_fonts.clone(),
408 custom_cursors: self.custom_cursors.clone(),
409 })
410 .init_resource::<ReactRegistry>()
411 .init_resource::<ReactRequestRegistry>()
412 .init_resource::<ReactEventRegistry>()
413 .init_resource::<PointerCapture>()
414 .init_resource::<OpApplyStats>()
415 // Unconditional so wasm compiles; it just stays `None` there.
416 .init_resource::<crate::reconcile::FrameStamp>()
417 .init_resource::<crate::ui_map::AtlasLayoutCache>()
418 .init_resource::<crate::scrollbar::ScrollbarTracks>()
419 .init_resource::<Fonts>()
420 .init_resource::<crate::cursor::CustomCursors>()
421 // SVG documents (`<image src="x.svg">`): parsed once per file into a
422 // resolution-independent asset, rasterized per node at laid-out size.
423 .init_asset::<crate::svg::SvgDocument>()
424 .register_asset_loader(crate::svg::SvgAssetLoader)
425 // Per-pointer refined svg shape hits — the picking refinement's
426 // handoff to the Interaction/event synthesis.
427 .init_resource::<crate::svg::pick::SvgPointerShapeHits>()
428 // The offscreen render-target ("portal") registry and its shared blank
429 // placeholder texture, created before the first portal can mount.
430 .init_resource::<crate::portal::RenderTargets>()
431 .add_systems(Startup, crate::portal::init_portal_placeholder)
432 // The `<surface>` registry (UI subtrees rendered into offscreen textures)
433 // and its single virtual pointer for in-world clicks.
434 .init_resource::<crate::surface::Surfaces>()
435 .add_systems(Startup, crate::surface::init_surface_pointer)
436 .add_systems(Startup, crate::layer::pick3d::init_transform3d_pointer)
437 .add_systems(Startup, setup)
438 .add_systems(
439 PreUpdate,
440 (dispatch_react_messages, dispatch_react_requests),
441 )
442 // Drive the surface virtual pointer (cursor → mesh UV → image render
443 // target) before `bevy_picking` processes inputs, so the offscreen UI is
444 // hit-tested with this frame's cursor.
445 .add_systems(
446 PreUpdate,
447 crate::surface::drive_surface_pointer
448 .before(bevy::picking::PickingSystems::ProcessInput),
449 )
450 // Remap the window cursor into 3D-transformed layers (inverse
451 // homography → virtual pointer) before input processing, like the
452 // surface driver. Reads last frame's matrices — the composite quad
453 // the user sees is last frame's too, so picking matches the visual.
454 .add_systems(
455 PreUpdate,
456 crate::layer::pick3d::drive_transform3d_pointer
457 .before(bevy::picking::PickingSystems::ProcessInput),
458 )
459 // Re-filter picking hits against the render-grade inherited clip
460 // (`CalculatedClip`) — bevy_ui 0.19's own clip check misses hits on
461 // scrolled/clipped-away nodes whose direct parent doesn't clip (see
462 // `pick_clip` module docs). Between the backends and the hover-map
463 // update, so every downstream consumer sees only visible hits.
464 .add_systems(
465 PreUpdate,
466 crate::pick_clip::filter_clipped_pointer_hits
467 .after(bevy::picking::PickingSystems::Backend)
468 .before(bevy::picking::PickingSystems::Hover),
469 )
470 // Scope hits around the transformed-layer remap (stale layout rects
471 // for the mouse, layer-membership for the virtual pointer) — after
472 // the clip filter for a deterministic filter order.
473 .add_systems(
474 PreUpdate,
475 crate::layer::pick3d::suppress_transformed_layer_hits
476 .after(bevy::picking::PickingSystems::Backend)
477 .after(crate::pick_clip::filter_clipped_pointer_hits)
478 .before(bevy::picking::PickingSystems::Hover),
479 )
480 // Refine hits on JSX `<svg>` nodes into hits on the topmost painted
481 // shape under the cursor (svg::pick module docs). Last of the three
482 // hit rewriters — after the transform3d suppression: suppression must
483 // settle first so a refinement only ever amplifies a SURVIVING svg-node
484 // hit (refining a hit that suppression then strips would leave a
485 // shape hit with no live node under it).
486 .add_systems(
487 PreUpdate,
488 crate::svg::pick::refine_svg_pointer_hits
489 .after(bevy::picking::PickingSystems::Backend)
490 .after(crate::pick_clip::filter_clipped_pointer_hits)
491 .after(crate::layer::pick3d::suppress_transformed_layer_hits)
492 .before(bevy::picking::PickingSystems::Hover),
493 )
494 .add_systems(
495 Update,
496 (
497 apply_js_ops,
498 collect_ui_events,
499 // Forward window-global keystrokes to React as built-in named
500 // events (`onKeyDown`/`onKeyUp`). Node-independent: reads Bevy's
501 // `KeyboardInput` messages directly, no reconciler state needed.
502 crate::keyboard::collect_keyboard_events,
503 // Emit `pointerEnter`/`pointerLeave` from `Interaction` transitions
504 // (same signal as hover styling), for nodes with those handlers.
505 collect_hover_events,
506 collect_pointer_events.in_set(PointerCaptureSet),
507 // Wheel-scroll any `overflow: scroll` node under the cursor, and
508 // deliver raw wheel deltas to any `onWheel` node. Both in the same
509 // set, after `collect_pointer_events`, so their `PointerCapture::over_ui`
510 // claim survives (that system *assigns* `over_ui`) and world systems
511 // (ordered `.after(PointerCaptureSet)`) see it. (A sub-tuple: the outer
512 // tuple is at Bevy's arity limit.)
513 (
514 crate::scroll::apply_scroll
515 .in_set(PointerCaptureSet)
516 .after(collect_pointer_events),
517 crate::scroll::collect_wheel_events
518 .in_set(PointerCaptureSet)
519 .after(collect_pointer_events),
520 // Hovering a scrollbar part claims the hover channel (the
521 // widget has `Hovered`, not `Interaction`, so a press on it
522 // would otherwise start a world grab); a thumb drag claims
523 // the pointer outright and snaps any eased scroll state. In the set,
524 // after `collect_pointer_events` so its `over_ui` claim survives.
525 crate::scrollbar::bridge_scrollbar_capture
526 .in_set(PointerCaptureSet)
527 .after(collect_pointer_events),
528 ),
529 // Ease `ScrollPosition` toward the target the controlled write
530 // (`apply_js_ops`) and the wheel (`PointerCaptureSet`) set this frame.
531 // Runs after both so it eases toward the freshest target.
532 crate::transition::drive_scroll_transition
533 .after(apply_js_ops)
534 .after(PointerCaptureSet),
535 // Report scroll-offset changes (wheel, controlled write, or an eased
536 // frame) to JS for any node with an `onScroll` handler. After the ease
537 // so it sees the moved offset; after the op drain so a controlled write
538 // is already seeded into the dedup map (no echo).
539 collect_scroll_events
540 .after(crate::transition::drive_scroll_transition)
541 .after(PointerCaptureSet)
542 .after(apply_js_ops),
543 // After the op drain so this frame's `StyleVariants` writes are
544 // visible; the ordering forces a command sync point first.
545 apply_interaction_styles.after(apply_js_ops),
546 // Ease `transform`/`opacity`/`backgroundColor` toward the target the
547 // style appliers just wrote. After `apply_interaction_styles` (and
548 // thus the op drain) so the eased value lands last and a coincident
549 // re-render's snap never wins.
550 crate::transition::drive_transitions.after(apply_interaction_styles),
551 // World-anchored overlays reposition after the op drain so they
552 // override this frame's static `left`/`top`.
553 crate::anchor::position_anchored_nodes.after(apply_js_ops),
554 // Repaint `<canvas>` textures after their surfaces/sizes update,
555 // and report layout resizes to JS (the surface just cleared — so
556 // the app / the runtime's declarative replay redraws). Both read
557 // last frame's `ComputedNode`. The cursor driver rides here too —
558 // it also reads last frame's `ComputedNode` after the op drain
559 // (freshly-stamped `NodeCursor`s visible), and this sub-tuple keeps
560 // the outer tuple under Bevy's arity limit.
561 (
562 crate::canvas::update_canvas_surfaces.after(apply_js_ops),
563 collect_canvas_resize_events.after(apply_js_ops),
564 // Repaint svg surfaces the same way — svg-mode `<image>`
565 // documents and JSX `<svg>` shape trees: reads last
566 // frame's `ComputedNode` after the op drain (fresh
567 // `SvgSurface`s AND the flushed queued `SvgShape`/child
568 // writes visible — the JSX dirt derivation needs them
569 // same-frame), rasters at laid-out size. Also after the
570 // animation appliers: their shape-attr stage writes driven
571 // `SvgShape` seeds, and the raster must read them the same
572 // frame or a driven animation paints one frame late
573 // (pinned by `driven_shape_attr_repaints_same_frame`).
574 // With animations disabled the set is empty and the edge
575 // is vacuous. Also after `drive_transitions`: the shape
576 // transition channel writes eased `SvgShape` attrs and the
577 // raster must paint them the SAME frame (pinned by
578 // `eased_shape_attr_repaints_same_frame`).
579 crate::svg::update_svg_surfaces
580 .after(apply_js_ops)
581 .after(AnimationSet::Apply)
582 .after(crate::transition::drive_transitions),
583 crate::cursor::drive_cursor_icon.after(apply_js_ops),
584 // Spawn/teardown the Bevy scrollbar widget over each
585 // `overflow: scroll` container that declared a `scrollbar`
586 // style, then place its track over the container's edge. Both
587 // after the op drain (fresh `ScrollbarConfig`s visible); place
588 // after sync so the tracks exist. Bevy's `ScrollbarPlugin`
589 // (in `DefaultPlugins`) drives the thumb in `PostUpdate`.
590 crate::scrollbar::sync_scrollbars.after(apply_js_ops),
591 crate::scrollbar::position_scrollbars
592 .after(apply_js_ops)
593 .after(crate::scrollbar::sync_scrollbars),
594 // Paint each bar in its hover/pressed/base state (reads Bevy's
595 // `Hovered` + `ScrollbarDragState`). After sync so the entities exist.
596 crate::scrollbar::style_scrollbar_states
597 .after(crate::scrollbar::sync_scrollbars),
598 ),
599 // Bind `<portal>` nodes to their render-target textures after the
600 // op drain (so a freshly-spawned portal binds the same frame), then
601 // drive resolution + the snapshot camera lifecycle.
602 crate::portal::bind_portals.after(apply_js_ops),
603 crate::portal::drive_render_targets.after(crate::portal::bind_portals),
604 // Bind `<surface>` roots to their offscreen UI cameras after the op
605 // drain (so a freshly-mounted surface binds the same frame), then
606 // drive the snapshot camera lifecycle.
607 crate::surface::bind_surfaces.after(apply_js_ops),
608 crate::surface::drive_surfaces.after(crate::surface::bind_surfaces),
609 // Surface interaction: turn the virtual pointer's picking events on
610 // the offscreen subtree into `onClick`/`onPointer*` + hover/press
611 // styling. The picking events are produced in `PreUpdate`, so these
612 // read this frame's events.
613 collect_surface_clicks,
614 collect_surface_pointer_events,
615 collect_surface_hover_events,
616 apply_surface_interaction_styles,
617 ),
618 );
619
620 // The built-in `filter` registry (blur + the color-matrix ops), beside
621 // the other name-keyed registries above. Registration is
622 // `AssetServer`-free — shaders load lazily inside each entry's resolve.
623 crate::filters::register_builtin_filters(app);
624
625 // The built-in `"resize"` event + `bevy.window.size()` request (see
626 // `crate::window`). A separate `add_systems` call — the Update tuple
627 // above is at Bevy's arity cap. `.after(apply_js_ops)` so the initial
628 // size goes out the same frame the first batch applies.
629 app.add_systems(
630 Update,
631 crate::window::send_resize_events.after(apply_js_ops),
632 );
633
634 // The built-in gamepad events (see `crate::gamepad`). A separate
635 // `add_systems` call — the Update tuple above is at the arity cap.
636 // `.after(apply_js_ops)` so the late-join announce goes out the same
637 // frame the first batch applies. The `add_message` calls are idempotent
638 // (`InputPlugin` registers both in real apps) and keep the collector's
639 // and observers' params valid in headless `MinimalPlugins` apps.
640 app.init_resource::<crate::gamepad::GamepadRegistry>();
641 app.add_message::<bevy::input::gamepad::GamepadEvent>();
642 app.add_message::<bevy::input::gamepad::GamepadConnectionEvent>();
643 app.add_message::<bevy::input::gamepad::GamepadRumbleRequest>();
644 app.add_systems(
645 Update,
646 crate::gamepad::collect_gamepad_events.after(apply_js_ops),
647 );
648
649 // `backgroundImage` runtime systems (see `crate::background_image`).
650 // A separate `add_systems` call — the Update tuple above is at the
651 // arity cap. Ordered after the op drain AND the interaction restyle
652 // (both stamp `RBackgroundTexture` / insert `ImageNode`s; the explicit
653 // edges also force the command syncs), so a freshly-styled node binds
654 // and gets its DPI-corrected tile scale the same frame.
655 app.add_systems(
656 Update,
657 (
658 crate::background_image::bind_background_textures
659 .after(apply_js_ops)
660 .after(apply_interaction_styles),
661 crate::background_image::sync_background_tile_scale
662 .after(apply_js_ops)
663 .after(apply_interaction_styles),
664 ),
665 );
666
667 // Layer promotion (see `crate::layer`). Main-world state registers
668 // unconditionally so headless wiring tests build; the render half is
669 // gated above with the rest of the render-only setup. A separate
670 // `add_systems` call — the Update tuple above is at the arity cap.
671 // Ordering: after the op drain (fresh props/dirty marks; the explicit
672 // constraint also forces a command sync so markers are visible), and
673 // before every later alpha writer this frame — the interaction
674 // restyle, transitions (ordered after it), and the animation appliers
675 // — so they all see the final promotion state (no cross-stage
676 // ping-pong).
677 app.init_resource::<crate::layer::LayerMembership>();
678 app.init_resource::<crate::layer::LayersRegistry>();
679 app.init_resource::<crate::layer::LayerContentDirt>();
680 app.init_resource::<crate::layer::LayerRepaintState>();
681 app.init_resource::<crate::layer::clip::LayerClips>();
682 app.add_systems(
683 Update,
684 (
685 crate::layer::evaluate_layer_promotions
686 .after(apply_js_ops)
687 .before(apply_interaction_styles)
688 .before(AnimationSet::Apply),
689 // Async `<image>` texture arrivals have no other write site the
690 // layer cache could tap.
691 crate::layer::watch_layer_image_assets,
692 // Override `ui_focus_system`'s geometric (stale-rect) verdict
693 // for members of visually-transformed layers with the virtual
694 // pointer's, before styling/enter-leave/drag-position readers.
695 crate::layer::pick3d::correct_transformed_interactions
696 .before(apply_interaction_styles)
697 .before(collect_hover_events)
698 .before(crate::reconcile::collect_pointer_events),
699 ),
700 );
701 // Synthesize `Interaction`/`RelativeCursorPosition`/user-space cursor
702 // for handler-bearing SVG shape children from the hover map + the
703 // refined per-shape hits (`svg::interact` module docs) — shapes are
704 // Node-less, so `ui_focus_system` never sees them. Same slot as
705 // `correct_transformed_interactions` above: before styling,
706 // enter/leave, and drag-position readers so they all see the
707 // synthesized state — and AFTER `correct_transformed_interactions`
708 // itself: for a handler-bearing shape inside a visually-transformed
709 // layer both systems write the same `RelativeCursorPosition` (pick3d
710 // writes `normalized: None` — shapes have no `ComputedNode`), and
711 // without an explicit order the winner is nondeterministic and the
712 // two writes ping-pong `Changed` every frame. The svg synthesis must
713 // land last: it holds the refined user-space hit. A separate
714 // `add_systems` call — the Update tuple above is at Bevy's arity cap.
715 app.add_systems(
716 Update,
717 crate::svg::interact::sync_shape_interactions
718 .after(crate::layer::pick3d::correct_transformed_interactions)
719 .before(apply_interaction_styles)
720 .before(collect_hover_events)
721 .before(crate::reconcile::collect_pointer_events),
722 );
723 // Resolve each promoted root's wire `filter` and `backdropFilter`
724 // chains into packed render passes — the two instances of
725 // `crate::filters::resolve_chains`. After the interaction restyle —
726 // the last input writer this frame (and, transitively, after the
727 // promotion evaluator, so `Added<PromotedLayer>` is visible) — and
728 // before the transition/animation appliers so filter-param writes
729 // land on an already-resolved chain. Its own `add_systems` call — the
730 // Update tuple above is at the arity cap.
731 app.add_systems(
732 Update,
733 (
734 crate::filters::resolve_chains::<
735 crate::filters::FilterInput,
736 crate::filters::ResolvedFilterChain,
737 >,
738 crate::filters::resolve_chains::<
739 crate::filters::BackdropInput,
740 crate::filters::ResolvedBackdropChain,
741 >,
742 )
743 .after(apply_interaction_styles)
744 .before(crate::transition::drive_transitions)
745 .before(AnimationSet::Apply),
746 );
747 // After bevy_ui layout so capture rects/membership are this frame's
748 // geometry (extraction reads them the same frame, post-PostUpdate).
749 app.add_systems(
750 PostUpdate,
751 (
752 crate::layer::sync_layer_geometry.after(bevy::ui::UiSystems::Layout),
753 // Membership + geometry hashes are this frame's, and bevy_ui's
754 // text systems (PostLayout) have re-shaped — turn the frame's
755 // dirt into per-layer repaint decisions for extraction.
756 crate::layer::resolve_layer_repaints
757 .after(crate::layer::sync_layer_geometry)
758 .after(bevy::ui::UiSystems::PostLayout),
759 // Derives each transformed layer's composite matrix from this
760 // frame's layout; pushes composite-only dirt the resolver
761 // drains, so it slots between geometry sync and the resolver.
762 crate::layer::transform3d::sync_transform3d_matrices
763 .after(crate::layer::sync_layer_geometry)
764 .before(crate::layer::resolve_layer_repaints),
765 // Interior/quad clip maps: needs this frame's membership
766 // (sync_layer_geometry) and bevy_ui's final CalculatedClip
767 // (PostLayout). Deliberately NOT feeding
768 // resolve_layer_repaints — clip changes never dirty a
769 // capture (scrolling must stay a cache hit).
770 crate::layer::clip::sync_layer_clips
771 .after(crate::layer::sync_layer_geometry)
772 .after(bevy::ui::UiSystems::PostLayout),
773 // Re-clamps deferred controlled-scroll requests (a pin to the
774 // bottom in the same commit that grew the content) against
775 // fresh geometry.
776 crate::scroll::settle_controlled_scroll.after(bevy::ui::UiSystems::Layout),
777 ),
778 );
779 // Re-stamp the svg intrinsic measure after `bevy_ui`'s
780 // `update_image_content_size_system` (`UiSystems::Content`), which
781 // clears the `ContentSize` of any non-`Auto` `ImageNode` that changed
782 // this frame — every svg-mode prop rebuild re-inserts the `ImageNode`,
783 // so without this the measure vanishes on each delta. Before `Layout`
784 // so the re-stamp feeds the same frame's layout pass.
785 app.add_systems(
786 PostUpdate,
787 crate::svg::stamp_svg_measures
788 .after(bevy::ui::widget::update_image_content_size_system)
789 .before(bevy::ui::UiSystems::Layout),
790 );
791 app.add_react_request_handler(crate::window::handle_window_size_request);
792
793 // The built-in rumble messages (`bevy.gamepad.rumble` / `.stopRumble`,
794 // see `crate::gamepad`): registers the payloads in `ReactRegistry` and
795 // attaches the observers in one call each. `bevy.gamepad.getAll()` is
796 // the pull companion to `gamepadConnected` for late-mounted components.
797 app.add_react_handler(crate::gamepad::on_rumble);
798 app.add_react_handler(crate::gamepad::on_stop_rumble);
799 app.add_react_request_handler(crate::gamepad::handle_gamepad_get_all);
800
801 // Frame-start stamp for the devtools frame-wait / pre-apply split
802 // (native only — no usable `Instant::now` on wasm). `First`: before
803 // any work the pre-apply leg should attribute.
804 #[cfg(not(target_arch = "wasm32"))]
805 app.add_systems(bevy::app::First, crate::reconcile::mark_frame_start);
806
807 // `editableText` edits arrive as Bevy's `TextEditChange` trigger; an observer
808 // turns real changes into `"change"` and selection moves into `"select"` UI
809 // events. Two more observers bridge focus gain/loss to `"focus"`/`"blur"`.
810 app.add_observer(on_text_edit_change);
811 app.add_observer(on_focus_gained);
812 app.add_observer(on_focus_lost);
813
814 // Controlled-selection writes and the a11y value sync run after Bevy's
815 // text-edit pass (`EditableTextSystems`) so they see this frame's applied
816 // edits and resolve byte offsets against the current text.
817 app.add_systems(
818 PostUpdate,
819 (apply_pending_selections, sync_editable_a11y).after(bevy::text::EditableTextSystems),
820 );
821
822 // The animations engine is a separate plugin (its crate can't depend on
823 // this one). We add it and, as the only crate that sees both sides, order
824 // its `Apply` set after `apply_js_ops` so per-frame animation writes win
825 // over this frame's static style. Disabled → `anim_rx` drops here and
826 // `op_animate` sends are discarded.
827 if self.animations {
828 app.add_plugins(ReactUiAnimationsPlugin::new(anim_rx))
829 .configure_sets(Update, AnimationSet::Apply.after(apply_js_ops))
830 // Completion callbacks: settlements the engine reports (once per
831 // token-tagged driver, not per frame) go out to JS.
832 .add_systems(Update, forward_animation_settled.after(AnimationSet::Tick));
833 }
834
835 // The devtools inspector rides along by default (see `Self::devtools`).
836 // Registration is the only gate needed here: the plugin itself registers
837 // nothing in `--release` builds.
838 #[cfg(feature = "devtools")]
839 if self.devtools.enabled {
840 app.add_plugins(crate::devtools::DevtoolsPlugin::new(self.devtools.clone()));
841 }
842 }
843}
844
845/// Forward the animation engine's [`AnimationSettled`] messages to JS as
846/// [`Outbound::AnimationFinished`], resolving each to its completion callback.
847/// The engine crate can't depend on this one, so the bridging happens here.
848fn forward_animation_settled(
849 mut settled: MessageReader<AnimationSettled>,
850 outbound: Res<OutboundResource>,
851) {
852 for s in settled.read() {
853 let _ = outbound.0.send(Outbound::AnimationFinished {
854 id: s.id,
855 token: s.token,
856 finished: s.finished,
857 });
858 }
859}
860
861/// Marker for the UI root entity (reconciler node id 0 / `ROOT_ID`).
862/// `pub(crate)`: devtools' reserve-space mode insets this root's margins to
863/// push the app UI aside while the panel is docked.
864#[derive(Component)]
865pub(crate) struct UiRoot;
866
867/// Plugin configuration read by the startup system.
868#[derive(Resource)]
869struct ReactUiConfig {
870 default_font: Option<PathBuf>,
871 named_fonts: Vec<(String, PathBuf)>,
872 custom_cursors: Vec<(String, PathBuf, (u16, u16))>,
873}
874
875/// Fonts loaded from the plugin config, resolved to handles at startup. The
876/// default backs every `<text>` run; named entries are selected per element via
877/// the `fontFamily` style prop. Empty (unconfigured) → Bevy's built-in font.
878#[derive(Resource, Default)]
879pub struct Fonts {
880 pub default: Option<Handle<Font>>,
881 pub named: std::collections::HashMap<String, Handle<Font>>,
882}
883
884/// Carries the Bevy-side channel ends from `build` into `setup`.
885#[derive(Resource)]
886struct BridgeChannels {
887 ops_rx: Option<OpReceiver>,
888 outbound_tx: OutboundSender,
889}
890
891/// Receives app messages emitted by the React app (`emit(name, value)`).
892#[derive(Resource)]
893struct EmitReceiver(crossbeam_channel::Receiver<ReactMessage>);
894
895/// The single consumption point for React-emitted messages. Drains the channel
896/// each frame and routes every message to its registered typed payload via
897/// [`ReactRegistry`], triggering it for observers. Runs in `PreUpdate` so the
898/// triggers land before consumer `Update` systems run the same frame.
899fn dispatch_react_messages(
900 rx: Res<EmitReceiver>,
901 registry: Res<ReactRegistry>,
902 mut commands: Commands,
903) {
904 while let Ok(msg) = rx.0.try_recv() {
905 registry.dispatch(msg, &mut commands);
906 }
907}
908
909fn setup(
910 mut commands: Commands,
911 mut channels: ResMut<BridgeChannels>,
912 config: Res<ReactUiConfig>,
913 assets: Res<AssetServer>,
914) {
915 // Load configured fonts into the `Fonts` resource before the first
916 // `apply_js_ops` (Update) creates any text.
917 commands.insert_resource(Fonts {
918 default: config.default_font.as_ref().map(|p| assets.load(p.clone())),
919 named: config
920 .named_fonts
921 .iter()
922 .map(|(name, path)| (name.clone(), assets.load(path.clone())))
923 .collect(),
924 });
925 // Likewise load configured custom image cursors into the registry `drive_cursor_icon`
926 // resolves a `cursor` name against.
927 commands.insert_resource(crate::cursor::CustomCursors(
928 config
929 .custom_cursors
930 .iter()
931 .map(|(name, path, hotspot)| {
932 (
933 name.clone(),
934 CustomCursorImage {
935 handle: assets.load(path.clone()),
936 hotspot: *hotspot,
937 ..default()
938 },
939 )
940 })
941 .collect(),
942 ));
943
944 // The root container: a full-window flex column the reconciler appends
945 // top-level children into (it is reconciler node id 0). Children stack from
946 // the top, horizontally centered.
947 let root = commands
948 .spawn((
949 Node {
950 width: Val::Percent(100.0),
951 height: Val::Percent(100.0),
952 flex_direction: FlexDirection::Column,
953 justify_content: JustifyContent::FlexStart,
954 align_items: AlignItems::Center,
955 row_gap: Val::Px(16.0),
956 ..default()
957 },
958 // Layout scaffolding only: without an explicit `Pickable` the UI
959 // picking backend treats the full-window root as a blocking hit,
960 // which would make hover queries (`HoverMap`) report "over UI"
961 // everywhere. Element nodes opt in per-`FocusPolicy` instead.
962 bevy::picking::Pickable {
963 should_block_lower: false,
964 ..default()
965 },
966 UiRoot,
967 ))
968 .id();
969
970 // The shared overlay container for world-anchored nodes (`<anchor>`).
971 // `position_anchored_nodes` reparents every anchored overlay under this so it lives
972 // in its own hierarchy and never inflates an app container's flex layout or
973 // scrollable `content_size`. Zero-size at the window origin (absolute, left/top 0)
974 // with default `Overflow::visible`, so it neither clips its children nor intercepts
975 // pointer input; anchored nodes position themselves relative to its (0,0) corner.
976 // Spawned as the root's first child so the app subtree (appended later via ops)
977 // renders above it — add a `GlobalZIndex` here to lift overlays above app content.
978 commands.spawn((
979 Node {
980 position_type: PositionType::Absolute,
981 left: Val::Px(0.0),
982 top: Val::Px(0.0),
983 width: Val::Px(0.0),
984 height: Val::Px(0.0),
985 ..default()
986 },
987 crate::anchor::AnchorLayer,
988 ChildOf(root),
989 ));
990
991 let ops_rx = channels.ops_rx.take().expect("setup runs once");
992 commands.insert_resource(JsBridge::new(ops_rx, channels.outbound_tx.clone(), root));
993}
994
995#[cfg(test)]
996mod tests {
997 use super::*;
998
999 /// `is_captured` must cover every capture channel: a UI drag, hovering
1000 /// interactive UI, and a UI-consumed wheel each count as "the UI owns the
1001 /// pointer" for consumers that don't need finer grain.
1002 #[test]
1003 fn is_captured_covers_all_channels() {
1004 assert!(!PointerCapture::default().is_captured());
1005 assert!(
1006 PointerCapture {
1007 dragging: true,
1008 ..default()
1009 }
1010 .is_captured()
1011 );
1012 assert!(
1013 PointerCapture {
1014 over_ui: true,
1015 ..default()
1016 }
1017 .is_captured()
1018 );
1019 assert!(
1020 PointerCapture {
1021 wheel_captured: true,
1022 ..default()
1023 }
1024 .is_captured()
1025 );
1026 }
1027}